| """Phase 2 alignment-based markers: VOT, vowel formants, rhythm metrics. |
| |
| Requires: |
| - Montreal Forced Aligner (``mfa``) on PATH — install via conda: |
| conda install -c conda-forge montreal-forced-aligner |
| mfa model download acoustic spanish_mfa |
| mfa model download dictionary spanish_mfa |
| - praatio for TextGrid parsing (pip install praatio) |
| - parselmouth (already installed in Phase 1) |
| |
| Workflow: |
| 1. ``prepare_corpus`` — write .lab files from Whisper transcripts |
| 2. ``run_mfa_alignment`` — call ``mfa align`` via subprocess |
| 3. ``extract_alignment_markers`` — read TextGrid + audio → features |
| """ |
|
|
| from __future__ import annotations |
|
|
| import math |
| import shutil |
| import subprocess |
| import tempfile |
| from pathlib import Path |
|
|
| import numpy as np |
| import parselmouth |
| from parselmouth.praat import call |
| from praatio import textgrid as tgio |
|
|
| |
| |
| |
|
|
| VOWELS = {"a", "e", "i", "o", "u", |
| "a\u02D0", "e\u02D0", "i\u02D0", "o\u02D0", "u\u02D0"} |
|
|
| |
| |
| VOICELESS_STOPS = {"p", "t", "k", "t\u032A", "c"} |
| |
| |
| VOICED_STOPS = {"b", "d", "d\u032A", "\u0261", "g", "\u025F\u02DD"} |
| |
| APPROXIMANTS = {"\u03B2", "\u00F0", "\u0263", |
| "\u0279"} |
|
|
| |
| CORNER_VOWELS = {"a", "i", "u"} |
|
|
| |
| _SILENCE_LABELS = {"", "sil", "sp", "spn", "<eps>"} |
|
|
|
|
| def _is_vowel(phone: str) -> bool: |
| """True if *phone* is a vowel (including long variants).""" |
| return phone.lower().strip() in VOWELS |
|
|
|
|
| def _is_silence(phone: str) -> bool: |
| return phone.lower().strip() in _SILENCE_LABELS |
|
|
|
|
| |
| |
| |
|
|
| def prepare_corpus( |
| transcripts: dict[str, dict], |
| corpus_dir: str | Path, |
| audio_dir: str | Path, |
| ) -> Path: |
| """Create an MFA-compatible corpus from Whisper transcripts. |
| |
| Parameters |
| ---------- |
| transcripts : dict |
| Mapping ``{speaker_id: transcript_dict}`` where each |
| ``transcript_dict`` is the output of ``transcribe.transcribe()``. |
| corpus_dir : path |
| Directory to write the corpus into (created if needed). |
| audio_dir : path |
| Directory containing the original WAV files. |
| |
| Returns |
| ------- |
| Path to the corpus directory. |
| """ |
| corpus_dir = Path(corpus_dir) |
| corpus_dir.mkdir(parents=True, exist_ok=True) |
| audio_dir = Path(audio_dir) |
|
|
| for speaker_id, transcript in transcripts.items(): |
| |
| words = [] |
| for chunk in transcript["chunks"]: |
| text = chunk["text"].strip() |
| if text and text != "[*]": |
| |
| clean = text.strip("[]") |
| if clean: |
| words.append(clean) |
| lab_text = " ".join(words) |
|
|
| |
| audio_path = Path(transcript["audio_path"]) |
| if not audio_path.is_absolute(): |
| audio_path = audio_dir / audio_path.name |
| if not audio_path.exists(): |
| |
| candidates = list(audio_dir.glob(f"{speaker_id}*.[Ww][Aa][Vv]")) |
| if candidates: |
| audio_path = candidates[0] |
|
|
| |
| dest_wav = corpus_dir / f"{speaker_id}.wav" |
| dest_lab = corpus_dir / f"{speaker_id}.lab" |
|
|
| if not dest_wav.exists(): |
| |
| dest_wav.symlink_to(audio_path.resolve()) |
|
|
| dest_lab.write_text(lab_text, encoding="utf-8") |
|
|
| return corpus_dir |
|
|
|
|
| |
| |
| |
|
|
| def run_mfa_alignment( |
| corpus_dir: str | Path, |
| output_dir: str | Path, |
| dictionary: str = "spanish_mfa", |
| acoustic_model: str = "spanish_mfa", |
| num_jobs: int = 4, |
| clean: bool = True, |
| ) -> Path: |
| """Run ``mfa align`` on a prepared corpus. |
| |
| Returns the output directory containing TextGrid files. |
| Raises RuntimeError if ``mfa`` is not found or alignment fails. |
| """ |
| corpus_dir = Path(corpus_dir) |
| output_dir = Path(output_dir) |
| output_dir.mkdir(parents=True, exist_ok=True) |
|
|
| mfa_bin = shutil.which("mfa") |
| if mfa_bin is None: |
| raise RuntimeError( |
| "Montreal Forced Aligner (mfa) not found on PATH.\n" |
| "Install via conda:\n" |
| " conda install -c conda-forge montreal-forced-aligner\n" |
| " mfa model download acoustic spanish_mfa\n" |
| " mfa model download dictionary spanish_mfa" |
| ) |
|
|
| cmd = [ |
| mfa_bin, "align", |
| str(corpus_dir), |
| dictionary, |
| acoustic_model, |
| str(output_dir), |
| "--output_format", "long_textgrid", |
| "--num_jobs", str(num_jobs), |
| "--single_speaker", |
| ] |
| if clean: |
| cmd.append("--clean") |
|
|
| result = subprocess.run(cmd, capture_output=True, text=True, timeout=600) |
| if result.returncode != 0: |
| raise RuntimeError( |
| f"MFA alignment failed (exit {result.returncode}):\n" |
| f"STDOUT:\n{result.stdout[-2000:]}\n" |
| f"STDERR:\n{result.stderr[-2000:]}" |
| ) |
|
|
| return output_dir |
|
|
|
|
| |
| |
| |
|
|
| def parse_textgrid(tg_path: str | Path) -> dict: |
| """Parse an MFA TextGrid into word and phone interval lists. |
| |
| Returns |
| ------- |
| dict with keys ``"words"`` and ``"phones"``, each a list of |
| ``(start, end, label)`` tuples. |
| """ |
| tg = tgio.openTextgrid(str(tg_path), includeEmptyIntervals=False) |
|
|
| phones = [] |
| words = [] |
|
|
| for tier_name in tg.tierNames: |
| tier = tg.getTier(tier_name) |
| lower = tier_name.lower() |
| entries = [(float(s), float(e), label) |
| for s, e, label in tier.entries] |
| if "phone" in lower: |
| phones = entries |
| elif "word" in lower: |
| words = entries |
|
|
| return {"words": words, "phones": phones} |
|
|
|
|
| |
| |
| |
|
|
| def _detect_burst_time( |
| sound: parselmouth.Sound, |
| start: float, |
| end: float, |
| ) -> float | None: |
| """Detect the burst release within a stop-consonant interval. |
| |
| Uses the intensity contour: the burst is the sharpest intensity |
| rise within the stop interval. Returns the absolute time of the |
| burst, or None if detection fails. |
| """ |
| duration = end - start |
| if duration < 0.010: |
| return None |
|
|
| try: |
| segment = sound.extract_part(start, end, |
| parselmouth.WindowShape.RECTANGULAR, |
| 1.0, False) |
| intensity = segment.to_intensity(minimum_pitch=400, time_step=0.0005) |
| except Exception: |
| return None |
|
|
| n = intensity.get_number_of_frames() |
| if n < 3: |
| return None |
|
|
| |
| best_rise = -np.inf |
| best_time = None |
| for i in range(2, n + 1): |
| t_prev = intensity.get_time_from_frame_number(i - 1) |
| t_cur = intensity.get_time_from_frame_number(i) |
| val_prev = intensity.get_value(t_prev) |
| val_cur = intensity.get_value(t_cur) |
|
|
| if np.isnan(val_prev) or np.isnan(val_cur): |
| continue |
|
|
| rise = val_cur - val_prev |
| if rise > best_rise: |
| best_rise = rise |
| best_time = t_cur |
|
|
| if best_time is None or best_rise < 1.0: |
| return None |
|
|
| |
| return start + best_time |
|
|
|
|
| def extract_vot( |
| phones: list[tuple[float, float, str]], |
| sound: parselmouth.Sound, |
| ) -> dict: |
| """Measure Voice Onset Time for voiceless and voiced stops. |
| |
| MFA places stop and vowel boundaries contiguously (no gap), so the |
| stop interval itself contains both closure and release/aspiration. |
| |
| For voiceless stops /p, t, k/: |
| VOT = stop_end − burst_time (positive: time from burst to vowel onset). |
| For voiced stops /b, d, g/: |
| VOT = stop_end − burst_time (may be short if voicing starts early). |
| |
| When burst detection fails, VOT is estimated as a fraction of the |
| stop duration (the release portion, typically the final ~40%). |
| |
| Returns per-phone-type mean VOT and individual measurements. |
| """ |
| measurements: list[dict] = [] |
|
|
| for i, (start, end, label) in enumerate(phones): |
| phone = label.lower().strip() |
|
|
| |
| if phone not in VOICELESS_STOPS and phone not in VOICED_STOPS: |
| continue |
|
|
| |
| next_phone = None |
| for j in range(i + 1, len(phones)): |
| _, _, nlabel = phones[j] |
| if not _is_silence(nlabel): |
| next_phone = phones[j] |
| break |
|
|
| if next_phone is None or not _is_vowel(next_phone[2]): |
| continue |
|
|
| vowel_start = next_phone[0] |
| stop_type = "voiceless" if phone in VOICELESS_STOPS else "voiced" |
| stop_dur_ms = (end - start) * 1000 |
|
|
| |
| burst_time = _detect_burst_time(sound, start, end) |
|
|
| if burst_time is not None: |
| |
| vot_ms = (end - burst_time) * 1000 |
| else: |
| |
| |
| vot_ms = stop_dur_ms * 0.4 |
| burst_time = start + (end - start) * 0.6 |
|
|
| |
| vot_ms = min(vot_ms, stop_dur_ms) |
| vot_ms = max(vot_ms, 0.0) |
|
|
| measurements.append({ |
| "phone": phone, |
| "type": stop_type, |
| "position_s": round(start, 3), |
| "stop_dur_ms": round(stop_dur_ms, 2), |
| "vot_ms": round(vot_ms, 2), |
| "burst_time_s": round(burst_time, 4), |
| "vowel_onset_s": round(vowel_start, 4), |
| }) |
|
|
| |
| voiceless_vots = [m["vot_ms"] for m in measurements |
| if m["type"] == "voiceless"] |
| voiced_vots = [m["vot_ms"] for m in measurements |
| if m["type"] == "voiced"] |
|
|
| per_phone: dict[str, list[float]] = {} |
| for m in measurements: |
| per_phone.setdefault(m["phone"], []).append(m["vot_ms"]) |
|
|
| phone_means = {p: round(float(np.mean(vs)), 2) |
| for p, vs in per_phone.items()} |
|
|
| return { |
| "voiceless_mean_vot_ms": round(float(np.mean(voiceless_vots)), 2) |
| if voiceless_vots else None, |
| "voiceless_sd_vot_ms": round(float(np.std(voiceless_vots, ddof=1)), 2) |
| if len(voiceless_vots) > 1 else None, |
| "voiced_mean_vot_ms": round(float(np.mean(voiced_vots)), 2) |
| if voiced_vots else None, |
| "voiced_sd_vot_ms": round(float(np.std(voiced_vots, ddof=1)), 2) |
| if len(voiced_vots) > 1 else None, |
| "per_phone_mean_ms": phone_means, |
| "n_voiceless": len(voiceless_vots), |
| "n_voiced": len(voiced_vots), |
| "measurements": measurements, |
| } |
|
|
|
|
| |
| |
| |
|
|
| def extract_vowel_formants( |
| phones: list[tuple[float, float, str]], |
| sound: parselmouth.Sound, |
| max_formant: float = 5500.0, |
| n_formants: int = 5, |
| ) -> dict: |
| """Extract F1/F2/F3 at the temporal midpoint of each vowel. |
| |
| Parameters |
| ---------- |
| max_formant : float |
| Maximum formant frequency for Burg analysis. |
| Use 5500 for female speakers, 5000 for male speakers. |
| Default 5500 (conservative for mixed/unknown gender). |
| |
| Returns per-vowel-type mean formants, all individual measurements, |
| and Vowel Space Area computed from corner vowels /a, i, u/. |
| """ |
| formant_obj = call(sound, "To Formant (burg)", |
| 0.025, n_formants, max_formant, 0.025, 50.0) |
|
|
| measurements: list[dict] = [] |
|
|
| for start, end, label in phones: |
| phone = label.lower().strip() |
| if not _is_vowel(phone): |
| continue |
|
|
| |
| vowel_id = phone.replace("\u02D0", "") |
| duration = end - start |
| if duration < 0.02: |
| continue |
|
|
| midpoint = (start + end) / 2 |
|
|
| f1 = call(formant_obj, "Get value at time", 1, midpoint, "Hertz", "Linear") |
| f2 = call(formant_obj, "Get value at time", 2, midpoint, "Hertz", "Linear") |
| f3 = call(formant_obj, "Get value at time", 3, midpoint, "Hertz", "Linear") |
|
|
| if np.isnan(f1) or np.isnan(f2): |
| continue |
|
|
| measurements.append({ |
| "vowel": vowel_id, |
| "midpoint_s": round(midpoint, 4), |
| "duration_ms": round(duration * 1000, 1), |
| "f1_hz": round(f1, 1), |
| "f2_hz": round(f2, 1), |
| "f3_hz": round(f3, 1) if not np.isnan(f3) else None, |
| }) |
|
|
| |
| vowel_data: dict[str, list[dict]] = {} |
| for m in measurements: |
| vowel_data.setdefault(m["vowel"], []).append(m) |
|
|
| per_vowel: dict[str, dict] = {} |
| for v, items in vowel_data.items(): |
| f1s = [it["f1_hz"] for it in items] |
| f2s = [it["f2_hz"] for it in items] |
| per_vowel[v] = { |
| "n": len(items), |
| "f1_mean_hz": round(float(np.mean(f1s)), 1), |
| "f1_sd_hz": round(float(np.std(f1s, ddof=1)), 1) if len(f1s) > 1 else None, |
| "f2_mean_hz": round(float(np.mean(f2s)), 1), |
| "f2_sd_hz": round(float(np.std(f2s, ddof=1)), 1) if len(f2s) > 1 else None, |
| } |
|
|
| |
| vsa = _compute_vsa(per_vowel) |
|
|
| |
| vfd = _compute_vfd(per_vowel) |
|
|
| return { |
| "per_vowel": per_vowel, |
| "vowel_space_area": vsa, |
| "vowel_formant_dispersion": vfd, |
| "n_total": len(measurements), |
| "measurements": measurements, |
| } |
|
|
|
|
| def _compute_vsa(per_vowel: dict[str, dict]) -> float | None: |
| """Vowel Space Area — triangle formed by /a/, /i/, /u/ in F1×F2 space. |
| |
| Uses the Shoelace formula for the area of a triangle: |
| VSA = 0.5 * |F1a(F2i - F2u) + F1i(F2u - F2a) + F1u(F2a - F2i)| |
| """ |
| corners = {} |
| for v in CORNER_VOWELS: |
| if v in per_vowel: |
| corners[v] = (per_vowel[v]["f1_mean_hz"], |
| per_vowel[v]["f2_mean_hz"]) |
|
|
| if len(corners) < 3: |
| return None |
|
|
| a = corners["a"] |
| i = corners["i"] |
| u = corners["u"] |
|
|
| area = 0.5 * abs( |
| a[0] * (i[1] - u[1]) + |
| i[0] * (u[1] - a[1]) + |
| u[0] * (a[1] - i[1]) |
| ) |
| return round(area, 1) |
|
|
|
|
| def _compute_vfd(per_vowel: dict[str, dict]) -> float | None: |
| """Vowel Formant Dispersion — mean Euclidean distance from centroid.""" |
| if not per_vowel: |
| return None |
|
|
| f1_all = [v["f1_mean_hz"] for v in per_vowel.values()] |
| f2_all = [v["f2_mean_hz"] for v in per_vowel.values()] |
|
|
| centroid_f1 = np.mean(f1_all) |
| centroid_f2 = np.mean(f2_all) |
|
|
| distances = [] |
| for v in per_vowel.values(): |
| d = math.sqrt((v["f1_mean_hz"] - centroid_f1) ** 2 + |
| (v["f2_mean_hz"] - centroid_f2) ** 2) |
| distances.append(d) |
|
|
| return round(float(np.mean(distances)), 1) |
|
|
|
|
| |
| |
| |
|
|
| def extract_rhythm_metrics( |
| phones: list[tuple[float, float, str]], |
| ) -> dict: |
| """Compute rhythm metrics from phone-level intervals. |
| |
| Returns %V, deltaC, deltaV, VarcoC, VarcoV, rPVI-C, nPVI-V. |
| """ |
| |
| cv_intervals: list[tuple[float, float, str]] = [] |
| for start, end, label in phones: |
| if _is_silence(label): |
| continue |
| category = "V" if _is_vowel(label) else "C" |
| cv_intervals.append((start, end, category)) |
|
|
| if not cv_intervals: |
| return _empty_rhythm() |
|
|
| |
| merged: list[tuple[float, float, str]] = [cv_intervals[0]] |
| for start, end, cat in cv_intervals[1:]: |
| prev_start, prev_end, prev_cat = merged[-1] |
| if cat == prev_cat and abs(start - prev_end) < 0.001: |
| |
| merged[-1] = (prev_start, end, cat) |
| else: |
| merged.append((start, end, cat)) |
|
|
| |
| c_durations = [(e - s) * 1000 for s, e, cat in merged if cat == "C"] |
| v_durations = [(e - s) * 1000 for s, e, cat in merged if cat == "V"] |
|
|
| if len(c_durations) < 2 or len(v_durations) < 2: |
| return _empty_rhythm() |
|
|
| c_arr = np.array(c_durations) |
| v_arr = np.array(v_durations) |
| total_dur = sum(c_durations) + sum(v_durations) |
|
|
| |
| pct_v = (sum(v_durations) / total_dur) * 100 if total_dur > 0 else 0 |
|
|
| |
| delta_c = float(np.std(c_arr, ddof=1)) |
| delta_v = float(np.std(v_arr, ddof=1)) |
|
|
| |
| mean_c = float(np.mean(c_arr)) |
| mean_v = float(np.mean(v_arr)) |
| varco_c = (delta_c / mean_c) * 100 if mean_c > 0 else 0 |
| varco_v = (delta_v / mean_v) * 100 if mean_v > 0 else 0 |
|
|
| |
| rpvi_c = float(np.mean(np.abs(np.diff(c_arr)))) |
|
|
| |
| npvi_v = _npvi(v_arr) |
|
|
| return { |
| "pct_v": round(pct_v, 2), |
| "delta_c_ms": round(delta_c, 2), |
| "delta_v_ms": round(delta_v, 2), |
| "varco_c": round(varco_c, 2), |
| "varco_v": round(varco_v, 2), |
| "rpvi_c": round(rpvi_c, 2), |
| "npvi_v": round(npvi_v, 2), |
| "n_c_intervals": len(c_durations), |
| "n_v_intervals": len(v_durations), |
| "mean_c_ms": round(mean_c, 2), |
| "mean_v_ms": round(mean_v, 2), |
| } |
|
|
|
|
| def _npvi(durations: np.ndarray) -> float: |
| """Normalized Pairwise Variability Index. |
| |
| nPVI = 100 * (1/(n-1)) * Σ |d_k - d_{k+1}| / ((d_k + d_{k+1}) / 2) |
| """ |
| n = len(durations) |
| if n < 2: |
| return 0.0 |
| total = 0.0 |
| for k in range(n - 1): |
| avg = (durations[k] + durations[k + 1]) / 2 |
| if avg > 0: |
| total += abs(durations[k] - durations[k + 1]) / avg |
| return 100.0 * total / (n - 1) |
|
|
|
|
| def _empty_rhythm() -> dict: |
| return { |
| "pct_v": None, "delta_c_ms": None, "delta_v_ms": None, |
| "varco_c": None, "varco_v": None, |
| "rpvi_c": None, "npvi_v": None, |
| "n_c_intervals": 0, "n_v_intervals": 0, |
| "mean_c_ms": None, "mean_v_ms": None, |
| } |
|
|
|
|
| |
| |
| |
|
|
| def extract_alignment_markers( |
| audio_path: str | Path, |
| textgrid_path: str | Path, |
| max_formant: float = 5500.0, |
| ) -> dict: |
| """Extract all Phase-2 alignment-based markers. |
| |
| Parameters |
| ---------- |
| audio_path : path |
| Path to the WAV file. |
| textgrid_path : path |
| Path to the MFA-produced TextGrid. |
| max_formant : float |
| Maximum formant frequency for Burg analysis (5500 for female, |
| 5000 for male, default 5500). |
| |
| Returns |
| ------- |
| dict with keys ``"vot"``, ``"vowel_formants"``, ``"rhythm"``. |
| """ |
| sound = parselmouth.Sound(str(audio_path)) |
| tg_data = parse_textgrid(textgrid_path) |
| phones = tg_data["phones"] |
|
|
| if not phones: |
| return {"vot": {}, "vowel_formants": {}, "rhythm": _empty_rhythm()} |
|
|
| vot = extract_vot(phones, sound) |
| formants = extract_vowel_formants(phones, sound, max_formant=max_formant) |
| rhythm = extract_rhythm_metrics(phones) |
|
|
| return { |
| "vot": vot, |
| "vowel_formants": formants, |
| "rhythm": rhythm, |
| } |
|
|