""" Pitch accent (HL) scoring using librosa F0 analysis. Segments audio into equal mora-length windows, classifies each mora as H or L based on median F0, then compares to the expected HL pattern. """ import numpy as np import librosa def extract_pitch_pattern(audio: np.ndarray, mora_count: int, sr: int = 16000) -> tuple[str, list[float]]: """ Extract HL pitch pattern from audio array. Args: audio: Float32 numpy array (mono, 16kHz). mora_count: Number of moras to segment audio into. sr: Sample rate (default 16000). Returns: Tuple of (pattern_string e.g. "LHH", list of per-mora median F0 values). Returns ("", []) if audio is silent or F0 extraction fails. """ if mora_count <= 0 or len(audio) == 0: return "", [] # Extract F0 using librosa pyin (probabilistic YIN) — good for speech try: f0, voiced_flag, _ = librosa.pyin( audio, fmin=librosa.note_to_hz("C2"), # ~65 Hz fmax=librosa.note_to_hz("C6"), # ~1047 Hz sr=sr, ) except Exception: return "", [] # Replace unvoiced frames (NaN or 0) with NaN f0 = np.where((f0 is None) | np.isnan(f0) | (f0 == 0), np.nan, f0) if np.all(np.isnan(f0)): return "", [] # Segment F0 into mora_count equal windows n_frames = len(f0) window_size = n_frames / mora_count mora_f0_medians = [] for i in range(mora_count): start = int(round(i * window_size)) end = int(round((i + 1) * window_size)) segment = f0[start:end] voiced = segment[~np.isnan(segment)] if len(voiced) == 0: mora_f0_medians.append(np.nan) else: mora_f0_medians.append(float(np.median(voiced))) # Fill any NaN mora with global median so classification still works global_median_arr = [v for v in mora_f0_medians if not np.isnan(v)] if not global_median_arr: return "", [] global_median = float(np.median(global_median_arr)) mora_f0_medians = [v if not np.isnan(v) else global_median for v in mora_f0_medians] # Classify: H if >= global median, else L threshold = global_median pattern = "".join("H" if f >= threshold else "L" for f in mora_f0_medians) return pattern, mora_f0_medians def compute_accent_score(detected_pattern: str, expected_pattern: str) -> int: """ Compute accent similarity score 0-100. Uses positional mora matching. Truncates longer pattern to shorter length. Args: detected_pattern: e.g. "LHH" expected_pattern: e.g. "LHH" Returns: Integer score 0-100. """ if not detected_pattern or not expected_pattern: return 0 # Align to same length (shorter wins) min_len = min(len(detected_pattern), len(expected_pattern)) d = detected_pattern[:min_len] e = expected_pattern[:min_len] matches = sum(1 for a, b in zip(d, e) if a == b) return round((matches / min_len) * 100)