language_attrition_speech_variables / alignment_markers.py
akki2825's picture
Upload 7 files
7cc1bbc verified
Raw
History Blame Contribute Delete
21.3 kB
"""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
# ---------------------------------------------------------------------------
# Phone‐set definitions (MFA Spanish IPA inventory)
# ---------------------------------------------------------------------------
VOWELS = {"a", "e", "i", "o", "u",
"a\u02D0", "e\u02D0", "i\u02D0", "o\u02D0", "u\u02D0"} # long variants
# Voiceless stops — primary VOT targets (Spanish short-lag → German long-lag)
# MFA Spanish uses dental t̪ and palatal c alongside plain p, k
VOICELESS_STOPS = {"p", "t", "k", "t\u032A", "c"} # t̪ = dental t
# Voiced stops — secondary VOT targets (Spanish lead voicing)
# MFA Spanish uses dental d̪ alongside plain b, ɡ/g, and palatal ɟʝ
VOICED_STOPS = {"b", "d", "d\u032A", "\u0261", "g", "\u025F\u02DD"} # d̪, ɡ, ɟʝ
# Voiced stop approximant allophones (excluded from VOT)
APPROXIMANTS = {"\u03B2", "\u00F0", "\u0263", # β ð ɣ
"\u0279"} # ɹ (if present)
# Corner vowels for Vowel Space Area
CORNER_VOWELS = {"a", "i", "u"}
# All consonant-like phones (anything not a vowel and not silence)
_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
# ---------------------------------------------------------------------------
# 1. Corpus preparation (Whisper transcript → .lab files)
# ---------------------------------------------------------------------------
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():
# Get clean text (strip disfluency markers, keep real words)
words = []
for chunk in transcript["chunks"]:
text = chunk["text"].strip()
if text and text != "[*]":
# Strip brackets from CrisperWhisper tokens like [UH]
clean = text.strip("[]")
if clean:
words.append(clean)
lab_text = " ".join(words)
# Resolve audio file
audio_path = Path(transcript["audio_path"])
if not audio_path.is_absolute():
audio_path = audio_dir / audio_path.name
if not audio_path.exists():
# Try matching by speaker ID
candidates = list(audio_dir.glob(f"{speaker_id}*.[Ww][Aa][Vv]"))
if candidates:
audio_path = candidates[0]
# Write symlink to audio + .lab file
dest_wav = corpus_dir / f"{speaker_id}.wav"
dest_lab = corpus_dir / f"{speaker_id}.lab"
if not dest_wav.exists():
# Symlink so we don't copy large files
dest_wav.symlink_to(audio_path.resolve())
dest_lab.write_text(lab_text, encoding="utf-8")
return corpus_dir
# ---------------------------------------------------------------------------
# 2. MFA alignment
# ---------------------------------------------------------------------------
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
# ---------------------------------------------------------------------------
# 3. TextGrid parsing
# ---------------------------------------------------------------------------
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}
# ---------------------------------------------------------------------------
# 4a. VOT extraction
# ---------------------------------------------------------------------------
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
# Find the frame with the largest intensity rise (derivative)
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: # minimum 1 dB rise
return None
# Convert back to absolute time
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()
# Only measure stops followed by a vowel
if phone not in VOICELESS_STOPS and phone not in VOICED_STOPS:
continue
# Find the next non-silence phone
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
# Detect burst within the stop interval
burst_time = _detect_burst_time(sound, start, end)
if burst_time is not None:
# VOT = time from burst to stop/vowel boundary
vot_ms = (end - burst_time) * 1000
else:
# Fallback: estimate VOT as the final 40% of the stop
# (closure is ~60%, release+aspiration is ~40%)
vot_ms = stop_dur_ms * 0.4
burst_time = start + (end - start) * 0.6
# Sanity: VOT shouldn't exceed the stop duration
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),
})
# Aggregate by phone and type
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,
}
# ---------------------------------------------------------------------------
# 4b. Vowel formant extraction + Vowel Space Area
# ---------------------------------------------------------------------------
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
# Strip length marks for grouping
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,
})
# Per-vowel means
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,
}
# Vowel Space Area (triangle: /a/, /i/, /u/)
vsa = _compute_vsa(per_vowel)
# Vowel Formant Dispersion
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)
# ---------------------------------------------------------------------------
# 4c. Rhythm metrics
# ---------------------------------------------------------------------------
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.
"""
# Step 1: classify each phone as C or V, skip silence
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()
# Step 2: merge adjacent same-type intervals
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:
# Merge
merged[-1] = (prev_start, end, cat)
else:
merged.append((start, end, cat))
# Step 3: compute durations
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)
# %V — proportion of vocalic intervals
pct_v = (sum(v_durations) / total_dur) * 100 if total_dur > 0 else 0
# deltaC, deltaV — standard deviations
delta_c = float(np.std(c_arr, ddof=1))
delta_v = float(np.std(v_arr, ddof=1))
# VarcoC, VarcoV — variation coefficients (rate-normalized)
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 — raw Pairwise Variability Index for consonants
rpvi_c = float(np.mean(np.abs(np.diff(c_arr))))
# nPVI-V — normalized PVI for vowels
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,
}
# ---------------------------------------------------------------------------
# 5. Public entry point
# ---------------------------------------------------------------------------
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,
}