| """Derive the Phase-1 linguistic factors from an audio file + its transcript. |
| |
| No transcription happens here: the student uploads the existing word-timestamped |
| transcript (JSON), and we run markers.extract_markers (disfluency, fluency, |
| complexity + Phase-1 acoustic). No MFA / forced alignment, so Phase-2 markers |
| (VOT, vowel space, rhythm) are not produced. |
| |
| The output is one flat dict per recording, ready to stack into a table for |
| modeling. Column names are kept short and spreadsheet-friendly. |
| """ |
| from __future__ import annotations |
|
|
| import json |
| import os |
|
|
| import librosa |
|
|
| from markers import extract_markers |
|
|
| |
| FLATTEN = [ |
| ("Words", "word_count_clean", 0), |
| ("Duration_s", "duration_s", 1), |
| |
| ("FP_per_100w", "filled_pauses.per_100_words", 2), |
| ("EP_per_100w", "empty_pauses.per_100_words", 2), |
| ("RP_per_100w", "repetitions.per_100_words", 2), |
| ("RT_per_100w", "retractions.per_100_words", 2), |
| ("PauseMean_ms", "temporal.pause_mean_ms", 1), |
| |
| ("SpeechRate_spm", "temporal.speech_rate_spm", 2), |
| ("ArticRate_spm", "temporal.articulation_rate_spm", 2), |
| ("Phonation_pct", "temporal.phonation_time_ratio_pct", 2), |
| |
| ("MLU", "temporal.mlu_words", 2), |
| ("TTR", "lexical_diversity.ttr", 4), |
| ("MTLD", "lexical_diversity.mtld", 2), |
| ("CodeSwitch", "code_switching.count", 0), |
| |
| ("F0_Mean", "acoustic.f0.f0_mean_hz", 2), |
| ("F0_SD", "acoustic.f0.f0_sd_hz", 2), |
| ("F0_Range", "acoustic.f0.f0_range_hz", 2), |
| ("F0_CV", "acoustic.f0.f0_cv", 4), |
| ("F0_Slope", "acoustic.f0.f0_slope_hz_per_s", 4), |
| |
| ("Jitter_pct", "acoustic.voice_quality.jitter_local_pct", 4), |
| ("Shimmer_pct", "acoustic.voice_quality.shimmer_local_pct", 4), |
| ("HNR_dB", "acoustic.voice_quality.hnr_mean_db", 2), |
| ("H1H2_dB", "acoustic.spectral_tilt.h1_h2_mean_db", 2), |
| |
| ("MFCC1", "acoustic.mfcc.mfcc_1_mean", 2), |
| ("MFCC2", "acoustic.mfcc.mfcc_2_mean", 2), |
| ("MFCC5", "acoustic.mfcc.mfcc_5_mean", 2), |
| ] |
|
|
| FEATURE_COLUMNS = [c for c, _, _ in FLATTEN] |
|
|
|
|
| def _dig(d: dict, dotted: str): |
| cur = d |
| for part in dotted.split("."): |
| if not isinstance(cur, dict): |
| return None |
| cur = cur.get(part) |
| return cur |
|
|
|
|
| def flatten_markers(markers: dict, label: str) -> dict: |
| """Turn the nested extract_markers() output into one flat row.""" |
| row = {"Speaker": label} |
| for col, path, nd in FLATTEN: |
| v = _dig(markers, path) |
| if isinstance(v, (int, float)): |
| v = round(v, nd) if nd else int(round(v)) |
| row[col] = v |
| return row |
|
|
|
|
| def load_chunks(transcript_path: str) -> list[dict]: |
| """Read an uploaded transcript JSON into the chunk format extract_markers wants. |
| |
| Accepts either ``{"chunks": [...]}`` or a bare list. Each chunk needs a |
| ``text`` and a ``timestamp`` with ``start`` / ``end`` (seconds). The ``[*]`` |
| disfluency markers must be kept: filled-pause and pause markers depend on them. |
| """ |
| with open(transcript_path, encoding="utf-8") as f: |
| data = json.load(f) |
| raw = data.get("chunks", data) if isinstance(data, dict) else data |
| if not isinstance(raw, list): |
| raise ValueError("Transcript JSON must be a list of chunks or have a 'chunks' key.") |
| chunks = [] |
| for c in raw: |
| ts = c.get("timestamp") or {} |
| chunks.append({ |
| "text": c["text"], |
| "timestamp": {"start": ts.get("start"), "end": ts.get("end")}, |
| "confidence": c.get("confidence"), |
| }) |
| return chunks |
|
|
|
|
| def derive_features_with_text(audio_path: str, transcript_path: str, label: str | None = None): |
| """Derive features from (audio, transcript); return (flat row, transcript text).""" |
| if not label: |
| label = os.path.splitext(os.path.basename(audio_path))[0] |
| chunks = load_chunks(transcript_path) |
| duration_s = round(librosa.get_duration(path=audio_path), 3) |
| transcript = {"chunks": chunks, "duration_s": duration_s, "audio_path": audio_path} |
| markers = extract_markers(transcript) |
| full_text = " ".join( |
| c["text"].strip() for c in chunks if c["text"].strip() and c["text"] != "[*]" |
| ) |
| return flatten_markers(markers, label), full_text |
|
|
|
|
| def derive_features(audio_path: str, transcript_path: str, label: str | None = None) -> dict: |
| """Derive features from (audio, transcript) and return the flat feature row.""" |
| row, _text = derive_features_with_text(audio_path, transcript_path, label) |
| return row |
|
|