MVP pronunciation assessment: phonemizer + alignment + scoring + AI feedback. Endpoint: POST /pronounce/assess

This commit is contained in:
“Naeel”
2026-05-23 17:34:45 +04:00
parent 2d94980038
commit 415b27edc2
3 changed files with 382 additions and 0 deletions
+4
View File
@@ -18,4 +18,8 @@ echo " done"
echo "=== rsync -> VM ==="
rsync -az -e "$SSH" /home/naeel/lang/dist/ ${VM}:/var/www/lyngvo/
echo "=== update server ==="
rsync -az -e "$SSH" /home/naeel/lang/server/ ${VM}:/opt/groq-proxy/
$SSH $VM 'systemctl restart groq-proxy && echo OK'
echo "=== https://lang.kube5s.ru ==="
+371
View File
@@ -0,0 +1,371 @@
#!/usr/bin/env python3
"""Pronunciation assessment engine — поверх Whisper API."""
from phonemizer import phonemize
import json, re, math
# ── Phoneme conversion ──────────────────────────────────────────────
def text_to_phonemes(text, lang="it"):
"""text → list of phoneme strings."""
raw = phonemize(text, language=lang, backend="espeak", strip=True,
preserve_punctuation=False, with_stress=False)
# espeak returns space-separated phonemes, but some are multi-char
# Parse carefully: split on spaces, merge tied phonemes
tokens = raw.split()
return tokens
def phonemes_to_syllables(phonemes):
"""Group phonemes into rough syllables (vowel = nucleus)."""
vowels = set("aeɛiouɔɑɒʌəɨʉɯʊɤeøɘɵɐœɶʏɪɞ")
syllables = []
cur = []
for p in phonemes:
cur.append(p)
# Any vowel-ish character means syllable nucleus
if any(c in vowels for c in p):
pass # keep collecting consonants after vowel in same syllable
# Simple heuristic: two consonants in a row → split before second
# Simpler approach: just split on vowel positions
result = []
buf = []
for p in phonemes:
buf.append(p)
has_vowel = any(c in vowels for c in p)
if has_vowel:
result.append("-".join(buf))
buf = []
if buf:
if result:
result[-1] = result[-1] + "-" + "-".join(buf)
else:
result.append("-".join(buf))
return result
# ── Expected pronunciation model ────────────────────────────────────
def build_expected(text, lang="it"):
"""Build expected pronunciation model for given text."""
phonemes = text_to_phonemes(text, lang)
syllables = phonemes_to_syllables(phonemes)
return {
"text": text,
"language": lang,
"phonemes": phonemes,
"syllables": syllables,
"phoneme_count": len(phonemes),
"syllable_count": len(syllables),
}
# ── Timing analysis ─────────────────────────────────────────────────
def analyze_timing(words, expected_duration=None):
"""Analyze word timing from Whisper output."""
if not words:
return {"error": "no words"}
times = []
for w in words:
dur = w.get("end", 0) - w.get("start", 0)
times.append({
"word": w.get("word", ""),
"start": w.get("start", 0),
"end": w.get("end", 0),
"duration": dur,
})
total = times[-1]["end"] - times[0]["start"] if times else 0
avg_speed = sum(t["duration"] for t in times) / len(times) if times else 0
# Rhythm score: lower std deviation = more natural rhythm
durs = [t["duration"] for t in times]
mean_dur = sum(durs) / len(durs) if durs else 1
variance = sum((d - mean_dur)**2 for d in durs) / len(durs) if durs else 0
rhythm_score = max(0, 100 - math.sqrt(variance) * 50)
return {
"words": times,
"total_duration": total,
"avg_word_duration": avg_speed,
"rhythm_score": round(rhythm_score, 1),
"timing_quality": "good" if rhythm_score > 70 else "ok" if rhythm_score > 40 else "poor",
}
# ── Phoneme comparison ──────────────────────────────────────────────
def levenshtein_ops(a, b):
"""Levenshtein with backtrace — returns list of (op, char_a, char_b)."""
m, n = len(a), len(b)
dp = [[0]*(n+1) for _ in range(m+1)]
for i in range(m+1): dp[i][0] = i
for j in range(n+1): dp[0][j] = j
for i in range(1, m+1):
for j in range(1, n+1):
cost = 0 if a[i-1] == b[j-1] else 1
dp[i][j] = min(dp[i-1][j]+1, dp[i][j-1]+1, dp[i-1][j-1]+cost)
# Backtrace
ops = []
i, j = m, n
while i > 0 or j > 0:
if i > 0 and j > 0 and a[i-1] == b[j-1]:
ops.append(("match", a[i-1], b[j-1]))
i -= 1; j -= 1
elif i > 0 and j > 0 and dp[i][j] == dp[i-1][j-1] + 1:
ops.append(("sub", a[i-1], b[j-1]))
i -= 1; j -= 1
elif i > 0 and dp[i][j] == dp[i-1][j] + 1:
ops.append(("del", a[i-1], ""))
i -= 1
else:
ops.append(("ins", "", b[j-1]))
j -= 1
ops.reverse()
return ops
def compare_phonemes(expected_phonemes, actual_phonemes):
"""Compare expected vs actual phonemes."""
ops = levenshtein_ops(expected_phonemes, actual_phonemes)
matches = sum(1 for o in ops if o[0] == "match")
substitutions = sum(1 for o in ops if o[0] == "sub")
deletions = sum(1 for o in ops if o[0] == "del")
insertions = sum(1 for o in ops if o[0] == "ins")
total = len(ops)
accuracy = round(matches / max(total, 1) * 100, 1)
errors = []
for op, expected, actual in ops:
if op != "match":
errors.append({"type": op, "expected": expected, "actual": actual})
return {
"accuracy": accuracy,
"total_phonemes": total,
"matches": matches,
"substitutions": substitutions,
"deletions": deletions,
"insertions": insertions,
"errors": errors,
}
# ── Italian-specific confusion penalties ────────────────────────────
ITALIAN_CONFUSION = {
# Russian → Italian common errors
("r", "ɾ"): 0.5, # rolled R
("l", "ʎ"): 0.7, # gli sound
("n", "ɲ"): 0.7, # gn sound
("e", "ɛ"): 0.3, # open e
("e", "e"): 0.0, # same
("o", "ɔ"): 0.3, # open o
# Double consonants (gemination)
("t", "tt"): 0.6,
("l", "ll"): 0.6,
("n", "nn"): 0.6,
}
def apply_confusion_penalty(errors):
"""Apply language-specific penalties to phoneme errors."""
weighted = 0
total = len(errors) if errors else 1
for err in errors:
key = (err.get("expected", ""), err.get("actual", ""))
penalty = ITALIAN_CONFUSION.get(key, 1.0)
weighted += penalty
return round(weighted / total, 2)
# ── Scoring ─────────────────────────────────────────────────────────
def compute_score(text_comparison, phoneme_comparison, timing_analysis, confusion_penalty):
"""Compute overall pronunciation score 0-100."""
# Text accuracy weight: 30%
text_score = text_comparison.get("score", 0)
# Phoneme accuracy weight: 35%
phoneme_score = phoneme_comparison.get("accuracy", 0)
# Timing/rhythm weight: 20%
timing_score = timing_analysis.get("rhythm_score", 0)
# Confusion penalty weight: 15% (inverted — lower penalty = higher score)
penalty_score = max(0, 100 - confusion_penalty * 100)
overall = text_score * 0.30 + phoneme_score * 0.35 + timing_score * 0.20 + penalty_score * 0.15
return round(overall, 1)
def quality_label(score):
if score >= 90: return "Отлично! 🇮🇹"
if score >= 75: return "Хорошо 👍"
if score >= 60: return "Неплохо 🙂"
if score >= 40: return "Нужна практика 📚"
return "Попробуй ещё раз 💪"
# ── AI Feedback ─────────────────────────────────────────────────────
def generate_feedback(assessment):
"""Generate human-readable feedback from assessment data."""
parts = []
score = assessment.get("overall_score", 0)
if score >= 90:
parts.append("🎉 Отличное произношение!")
elif score >= 75:
parts.append("👍 Хорошее произношение, есть небольшие недочёты.")
elif score >= 60:
parts.append("📚 Неплохо, но нужно поработать над звуками.")
else:
parts.append("💪 Требуется практика произношения.")
# Phoneme errors
errors = assessment.get("phoneme_comparison", {}).get("errors", [])
sub_errors = [e for e in errors if e["type"] == "sub"]
if sub_errors:
sample = sub_errors[:3]
parts.append("Звуки для отработки: " + ", ".join(
f"{e['expected']}{e['actual']}" for e in sample
))
del_errors = [e for e in errors if e["type"] == "del"]
if del_errors:
parts.append(f"Пропущено звуков: {len(del_errors)}.")
ins_errors = [e for e in errors if e["type"] == "ins"]
if ins_errors:
parts.append(f"Лишних звуков: {len(ins_errors)}.")
# Timing
timing = assessment.get("timing", {})
if timing.get("timing_quality") == "poor":
parts.append("⏱ Ритм неравномерный — попробуй говорить плавнее.")
return " ".join(parts)
# ── Full assessment ─────────────────────────────────────────────────
def assess(expected_text, whisper_result, lang="it"):
"""
Full pronunciation assessment.
Args:
expected_text: "buona sera"
whisper_result: {"text": "...", "words": [{word, start, end}, ...]}
Returns:
Full assessment dict with scores, phonemes, errors, feedback.
"""
# 1. Build expected model
expected = build_expected(expected_text, lang)
# 2. Build actual model from Whisper output
actual_text = whisper_result.get("text", "").strip()
actual_words = whisper_result.get("words", [])
actual_phonemes = text_to_phonemes(actual_text, lang) if actual_text else []
# 3. Text-level comparison (Levenshtein from frontend, or compute here)
# Use simple word accuracy
expected_words_norm = re.sub(r"[^\w\s]", "", expected_text.lower()).split()
actual_words_norm = re.sub(r"[^\w\s]", "", actual_text.lower()).split() if actual_text else []
word_matches = sum(1 for e, a in zip(expected_words_norm, actual_words_norm) if e == a)
text_score = round(word_matches / max(len(expected_words_norm), 1) * 100, 1)
text_comparison = {
"expected": expected_text,
"actual": actual_text,
"expected_words": expected_words_norm,
"actual_words": actual_words_norm,
"word_matches": word_matches,
"total_words": len(expected_words_norm),
"score": text_score,
}
# 4. Phoneme comparison
phoneme_comparison = compare_phonemes(expected["phonemes"], actual_phonemes)
# 5. Timing analysis
timing = analyze_timing(actual_words)
# 6. Confusion penalty
confusion_penalty = apply_confusion_penalty(phoneme_comparison.get("errors", []))
# 7. Overall score
overall = compute_score(text_comparison, phoneme_comparison, timing, confusion_penalty)
assessment = {
"overall_score": overall,
"quality": quality_label(overall),
"expected": expected,
"actual": {
"text": actual_text,
"phonemes": actual_phonemes,
"words": actual_words,
},
"text_comparison": text_comparison,
"phoneme_comparison": phoneme_comparison,
"timing": timing,
"confusion_penalty": confusion_penalty,
"language": lang,
}
# 8. Feedback
assessment["feedback"] = generate_feedback(assessment)
return assessment
# ── Flask endpoint ──────────────────────────────────────────────────
def register_routes(app):
from flask import request
@app.route("/pronounce/assess", methods=["POST"])
def pronounce_assess():
try:
data = request.get_json(force=True)
expected = data.get("expected", "").strip()
whisper_result = data.get("whisper", {})
lang = data.get("lang", "it")
if not expected:
return {"error": "no expected text"}, 400
if not whisper_result.get("text"):
return {"error": "no whisper result"}, 400
result = assess(expected, whisper_result, lang)
result["_version"] = "1.0.0"
print(f"[PRONOUNCE] \"{expected}\" → score={result['overall_score']} "
f"phonemes={result['phoneme_comparison']['accuracy']}% "
f"rhythm={result['timing']['rhythm_score']}", flush=True)
resp = app.make_response((json.dumps(result, ensure_ascii=False), 200))
resp.headers.update({
"Access-Control-Allow-Origin": "*",
"Content-Type": "application/json; charset=utf-8",
})
return resp
except Exception as e:
print(f"[PRONOUNCE] ERROR: {e}", flush=True)
return {"error": str(e)}, 500
@app.route("/pronounce/health", methods=["GET"])
def pronounce_health():
return {
"status": "ok",
"phonemizer": "espeak",
"languages": ["it", "en", "fr", "de", "es", "ru"],
"version": "1.0.0",
}
+7
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env python3
from flask import Flask, request, Response
import requests
import json
app = Flask(__name__)
GROQ_BASE = "https://api.proxyapi.ru/openai"
@@ -11,6 +12,12 @@ CORS = {
"Access-Control-Allow-Headers": "Authorization, Content-Type",
}
# Импорт pronounce после создания app
import sys, os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from pronounce import register_routes
register_routes(app)
@app.route("/<path:path>", methods=["OPTIONS"])
def options(path):
return Response(status=204, headers=CORS)