akki2825's picture
Upload 7 files
7cc1bbc verified
Raw
History Blame Contribute Delete
4.64 kB
"""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
# (column label, dotted path into extract_markers() output, decimals)
FLATTEN = [
("Words", "word_count_clean", 0),
("Duration_s", "duration_s", 1),
# disfluency (per 100 words)
("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),
# fluency
("SpeechRate_spm", "temporal.speech_rate_spm", 2),
("ArticRate_spm", "temporal.articulation_rate_spm", 2),
("Phonation_pct", "temporal.phonation_time_ratio_pct", 2),
# complexity
("MLU", "temporal.mlu_words", 2),
("TTR", "lexical_diversity.ttr", 4),
("MTLD", "lexical_diversity.mtld", 2),
("CodeSwitch", "code_switching.count", 0),
# acoustic: pitch
("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),
# acoustic: voice quality
("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),
# acoustic: spectral shape
("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