"""Deferred quantitative assessment. Never drives playback or speak()."""
|
|
from __future__ import annotations
|
|
import difflib
|
import os
|
import re
|
from typing import Any, Dict, List, Optional
|
|
from .playback import PLAY_SPEED_DEFAULT, clamp_play_speed
|
|
LISTEN_RATIO_DEFAULT = 1.2
|
LISTEN_RATIO_MIN = 1.2
|
LISTEN_RATIO_MAX = 1.5
|
LISTEN_WINDOW_MIN_MS = 800
|
METHOD_TEXT_SIMILARITY_V0 = "text_similarity_v0"
|
|
|
def looks_like_path(text: str) -> bool:
|
"""True if text is an SD / file path, not a transcript line."""
|
raw = (text or "").strip().replace("\\", "/")
|
if not raw:
|
return False
|
lower = raw.lower()
|
if lower.endswith((".mp3", ".txt", ".wav", ".opus", ".p3")):
|
return True
|
if "/lessons/" in lower:
|
return True
|
if raw.startswith("/") and "/" in raw[1:]:
|
return True
|
return False
|
|
|
def clip_stem(clip: str) -> str:
|
base = os.path.basename((clip or "").replace("\\", "/"))
|
return os.path.splitext(base)[0]
|
|
|
def clip_basename(clip: str) -> str:
|
return os.path.basename((clip or "").replace("\\", "/"))
|
|
|
def normalize(text: str) -> str:
|
from core.utils.util import remove_punctuation_and_length
|
|
_, filtered = remove_punctuation_and_length(text or "")
|
return re.sub(r"\s+", "", (filtered or "").lower())
|
|
|
def text_similarity_v0(asr_text: str, expected_text: str) -> float:
|
a = normalize(asr_text)
|
b = normalize(expected_text)
|
if not a or not b:
|
return 0.0
|
value = float(difflib.SequenceMatcher(None, a, b).ratio())
|
if value < 0.0:
|
return 0.0
|
if value > 1.0:
|
return 1.0
|
return value
|
|
|
def build_metrics(asr_text: str, expected_text: str) -> List[Dict[str, Any]]:
|
"""Return [{method, value}] or []. Never includes passed/grade."""
|
expected = (expected_text or "").strip()
|
asr = (asr_text or "").strip()
|
if not asr or not expected or looks_like_path(expected):
|
return []
|
return [
|
{
|
"method": METHOD_TEXT_SIMILARITY_V0,
|
"value": text_similarity_v0(asr, expected),
|
}
|
]
|
|
|
def silent_pcm_snapshot(duration_ms: int = 200, sample_rate: int = 16000) -> list:
|
"""Placeholder PCM so a silent follow-read still writes a wav."""
|
samples = max(1, int(sample_rate * max(duration_ms, 1) / 1000.0))
|
return [b"\x00\x00" * samples]
|
|
|
def pcm_snapshot_to_wav(snapshot: Any, sample_rate: int = 16000) -> bytes:
|
"""UDP/ASR PCM frames (list[bytes] or bytes) → WAV bytes. Empty input → b''."""
|
if not snapshot:
|
return b""
|
if isinstance(snapshot, (bytes, bytearray, memoryview)):
|
pcm = bytes(snapshot)
|
elif isinstance(snapshot, list):
|
parts = []
|
for item in snapshot:
|
if isinstance(item, (bytes, bytearray, memoryview)):
|
parts.append(bytes(item))
|
elif isinstance(item, str):
|
continue
|
else:
|
try:
|
parts.append(bytes(item))
|
except Exception:
|
continue
|
pcm = b"".join(parts)
|
else:
|
return b""
|
if len(pcm) % 2 == 1:
|
pcm = pcm[:-1]
|
if not pcm:
|
return b""
|
header = bytearray()
|
header.extend(b"RIFF")
|
header.extend((36 + len(pcm)).to_bytes(4, "little"))
|
header.extend(b"WAVE")
|
header.extend(b"fmt ")
|
header.extend((16).to_bytes(4, "little"))
|
header.extend((1).to_bytes(2, "little"))
|
header.extend((1).to_bytes(2, "little"))
|
header.extend(int(sample_rate).to_bytes(4, "little"))
|
header.extend((int(sample_rate) * 2).to_bytes(4, "little"))
|
header.extend((2).to_bytes(2, "little"))
|
header.extend((16).to_bytes(2, "little"))
|
header.extend(b"data")
|
header.extend(len(pcm).to_bytes(4, "little"))
|
return bytes(header) + pcm
|
|
|
def save_turn_wav(
|
session_id: str,
|
step_index: int,
|
wav_bytes: bytes,
|
conn: Any = None,
|
text: Optional[str] = None,
|
) -> Optional[str]:
|
"""Persist the follow-read utterance. Returns relative audio_id or None."""
|
if not wav_bytes:
|
return None
|
extra = f"{session_id}_{int(step_index)}" if session_id else str(int(step_index))
|
if conn is not None:
|
from core.audio_archive import archive_wav
|
|
archived = archive_wav(conn, wav_bytes, kind="asr", extra=extra, text=text)
|
if archived:
|
return archived
|
if not session_id:
|
return None
|
try:
|
from config.config_loader import get_project_dir
|
from core.audio_archive import write_sidecar_text
|
|
directory = os.path.join(get_project_dir(), "data", "task_sessions", "audio")
|
os.makedirs(directory, exist_ok=True)
|
filename = f"{session_id}_{int(step_index)}.wav"
|
path = os.path.join(directory, filename)
|
with open(path, "wb") as handle:
|
handle.write(wav_bytes)
|
if text is not None:
|
write_sidecar_text(path, text)
|
return filename
|
except Exception:
|
return None
|
|
|
def _metrics_from_api_payload(data: Any) -> List[Dict[str, Any]]:
|
if not isinstance(data, dict):
|
return []
|
raw = data.get("metrics")
|
if not isinstance(raw, list):
|
if data.get("method") and data.get("value") is not None:
|
raw = [data]
|
else:
|
raw = []
|
out: List[Dict[str, Any]] = []
|
for item in raw:
|
if not isinstance(item, dict):
|
continue
|
method = str(item.get("method") or "").strip()
|
if not method:
|
continue
|
try:
|
value = float(item.get("value"))
|
except (TypeError, ValueError):
|
continue
|
if value < 0.0:
|
value = 0.0
|
if value > 1.0:
|
value = 1.0
|
out.append({"method": method, "value": value})
|
return out
|
|
|
def call_assessment_endpoint(
|
endpoint: str,
|
asr_text: str,
|
expected_text: str,
|
wav_bytes: bytes,
|
extra: Optional[Dict[str, Any]] = None,
|
) -> List[Dict[str, Any]]:
|
"""POST ASR text + WAV to the scoring API. Failures return [] and never raise."""
|
url = (endpoint or "").strip()
|
if not url:
|
return []
|
try:
|
import httpx
|
except Exception:
|
return []
|
files = None
|
data = {
|
"asr_text": asr_text or "",
|
"expected_text": expected_text or "",
|
}
|
if extra:
|
for key, value in extra.items():
|
if value is None:
|
continue
|
data[str(key)] = str(value)
|
if wav_bytes:
|
files = {"audio": ("utterance.wav", wav_bytes, "audio/wav")}
|
try:
|
with httpx.Client(timeout=8.0) as client:
|
response = client.post(url, data=data, files=files)
|
if response.status_code >= 400:
|
return []
|
try:
|
payload = response.json()
|
except Exception:
|
return []
|
return _metrics_from_api_payload(payload)
|
except Exception:
|
return []
|
|
|
def evaluate_turn(
|
asr_text: str,
|
expected_text: str,
|
wav_bytes: bytes = b"",
|
cfg: Optional[Dict[str, Any]] = None,
|
extra: Optional[Dict[str, Any]] = None,
|
) -> List[Dict[str, Any]]:
|
"""Quantitative follow-read score. Text similarity always; HTTP endpoint optional.
|
|
Never speaks, never advances playback. Missing expected text still saves audio
|
and may still call the remote API.
|
"""
|
cfg = cfg or {}
|
by_method = {item["method"]: item for item in build_metrics(asr_text, expected_text)}
|
endpoint = str(cfg.get("endpoint") or cfg.get("url") or "").strip()
|
if endpoint:
|
for item in call_assessment_endpoint(
|
endpoint, asr_text or "", expected_text or "", wav_bytes or b"", extra
|
):
|
by_method[item["method"]] = item
|
return list(by_method.values())
|
|
|
def clamp_listen_ratio(raw: Any) -> float:
|
try:
|
value = float(raw)
|
except (TypeError, ValueError):
|
return LISTEN_RATIO_DEFAULT
|
if value < LISTEN_RATIO_MIN:
|
return LISTEN_RATIO_MIN
|
if value > LISTEN_RATIO_MAX:
|
return LISTEN_RATIO_MAX
|
return value
|
|
|
def resolve_listen_ratio(conn: Any = None, task: Optional[Dict[str, Any]] = None) -> float:
|
"""v0: task JSON constant. Hook for later per-user ratios."""
|
if task and task.get("listen_ratio") is not None:
|
return clamp_listen_ratio(task.get("listen_ratio"))
|
if conn is not None:
|
cfg = (getattr(conn, "config", None) or {}).get("app_runtime") or {}
|
if cfg.get("listen_ratio") is not None:
|
return clamp_listen_ratio(cfg.get("listen_ratio"))
|
return LISTEN_RATIO_DEFAULT
|
|
|
def listen_window_ms(
|
duration_ms: Any,
|
ratio: Any = None,
|
min_ms: int = LISTEN_WINDOW_MIN_MS,
|
speed: Any = None,
|
wall_clock: bool = True,
|
) -> int:
|
"""Pause after a shadowing clip. Not a fixed 2.5s.
|
|
Firmware reports wall-clock play time, which already includes speed
|
(slower rate → longer duration_ms). Window = that time × listen_ratio (1.2).
|
If duration_ms is the original file length, pass wall_clock=False and speed.
|
"""
|
clamped = clamp_listen_ratio(ratio if ratio is not None else LISTEN_RATIO_DEFAULT)
|
duration = 0
|
try:
|
duration = int(duration_ms or 0)
|
except (TypeError, ValueError):
|
duration = 0
|
if duration <= 0:
|
return int(min_ms)
|
played = duration
|
if not wall_clock:
|
rate = clamp_play_speed(speed) if speed is not None else PLAY_SPEED_DEFAULT
|
if rate <= 0:
|
rate = PLAY_SPEED_DEFAULT
|
played = int(round(duration / rate))
|
return max(int(min_ms), int(played * clamped))
|
|
|
def transcript_cache_dir(app_id: str = "", task_id: str = "") -> Optional[str]:
|
try:
|
from config.config_loader import get_project_dir
|
|
path = os.path.join(
|
get_project_dir(),
|
"data",
|
"lesson_cache",
|
app_id or "",
|
task_id or "",
|
)
|
if os.path.isdir(path):
|
return path
|
except Exception:
|
return None
|
return None
|
|
|
def _usable_text(value: Any) -> Optional[str]:
|
text = str(value).strip() if value is not None else ""
|
if not text or looks_like_path(text):
|
return None
|
return text
|
|
|
def _read_txt_file(path: str) -> Optional[str]:
|
try:
|
if not path or not os.path.isfile(path):
|
return None
|
with open(path, "r", encoding="utf-8") as handle:
|
return _usable_text(handle.read())
|
except Exception:
|
return None
|
|
|
def _read_cached_transcript(clip: str, cache_dir: Optional[str]) -> Optional[str]:
|
if not cache_dir or not os.path.isdir(cache_dir) or not clip:
|
return None
|
stem = clip_stem(clip)
|
candidates = [
|
os.path.join(cache_dir, f"{stem}.txt"),
|
os.path.join(cache_dir, os.path.splitext(clip_basename(clip))[0] + ".txt"),
|
]
|
name = (clip or "").replace("\\", "/")
|
marker = "/lessons/"
|
if marker in name:
|
rest = name.split(marker, 1)[1]
|
rest_txt = os.path.splitext(rest)[0] + ".txt"
|
candidates.append(os.path.join(cache_dir, rest_txt.replace("/", os.sep)))
|
for path in candidates:
|
text = _read_txt_file(path)
|
if text:
|
return text
|
return None
|
|
|
def resolve_expected_text(
|
task: Optional[Dict[str, Any]],
|
clips: Optional[List[str]],
|
index: int,
|
cache_dir: Optional[str] = None,
|
) -> Optional[str]:
|
"""01.mp3 ↔ 01.txt contract. Never treat an SD path as expected_text."""
|
clips = list(clips or [])
|
task = task or {}
|
transcripts = task.get("transcripts") if isinstance(task.get("transcripts"), dict) else {}
|
sentences = task.get("sentences") or []
|
|
clip = ""
|
if 0 <= index < len(clips):
|
clip = str(clips[index] or "")
|
stem = clip_stem(clip)
|
basename = clip_basename(clip)
|
for key in (basename, stem, clip):
|
found = _usable_text(transcripts.get(key) if key else None)
|
if found:
|
return found
|
cached = _read_cached_transcript(clip, cache_dir)
|
if cached:
|
return cached
|
|
if 0 <= index < len(sentences):
|
found = _usable_text(sentences[index])
|
if found:
|
return found
|
return None
|