diff --git a/app.js b/app.js index 5ccb016..1d76a20 100644 --- a/app.js +++ b/app.js @@ -54,7 +54,20 @@ function renderCategories(categories) { card.setAttribute("tabindex", "0"); const emoji = CATEGORY_EMOJI[cat.name] || "📚"; - card.innerHTML = `${emoji} ${cat.display_name}
${cat.word_count} word${cat.word_count !== 1 ? "s" : ""}`; + const emojiSpan = document.createElement("span"); + emojiSpan.className = "emoji"; + emojiSpan.textContent = emoji; + const nameSpan = document.createElement("span"); + nameSpan.textContent = cat.display_name; + const lineBreak = document.createElement("br"); + const countSpan = document.createElement("span"); + countSpan.className = "small"; + countSpan.textContent = `${cat.word_count} word${cat.word_count !== 1 ? "s" : ""}`; + card.appendChild(emojiSpan); + card.appendChild(document.createTextNode(" ")); + card.appendChild(nameSpan); + card.appendChild(lineBreak); + card.appendChild(countSpan); const go = () => { const { lang, difficulty } = getState(); diff --git a/backend/SCORING.md b/backend/SCORING.md index 83bed6a..e1b6df5 100644 --- a/backend/SCORING.md +++ b/backend/SCORING.md @@ -341,7 +341,7 @@ If your voice's fundamental frequency differs from the reference speaker's by 40 ### A. Feature weights -**File:** `feature_comparator.py`, lines 14–20 +**File:** `feature_comparator.py`, lines 13–19 ```python WEIGHTS = { @@ -359,16 +359,16 @@ Must sum to 1.0. | Feature | Component | Sigma | File location | More lenient suggestion | |---------|-----------|-------|---------------|------------------------| -| Pitch | Contour (DTW) | 50 | `_compare_pitch`, line ~143 | 80–100 | -| Pitch | Mean | 30 | `_compare_pitch`, line ~146 | 50–60 | -| Pitch | Range | 40 | `_compare_pitch`, line ~150 | 40 (fine) | -| Formants | F1/F2/F3 (DTW) | 100 | `_compare_formants`, line ~163 | 200–300 | -| Intensity | Contour (DTW) | 10 | `_compare_intensity`, line ~186 | 15–20 | -| Intensity | Mean | 5 | `_compare_intensity`, line ~190 | 10–15 | -| Duration | Time ratio | 0.3 | `_compare_duration`, line ~208 | 0.3 (fine) | -| Duration | Voiced fraction | 0.2 | `_compare_duration`, line ~218 | 0.2 (fine) | -| Voice quality | Jitter | 0.01 | `_compare_voice_quality`, line ~231 | 0.02–0.03 | -| Voice quality | Shimmer | 0.05 | `_compare_voice_quality`, line ~232 | 0.08–0.10 | +| Pitch | Contour (DTW) | 50 | `_compare_pitch` | 80–100 | +| Pitch | Mean | 30 | `_compare_pitch` | 50–60 | +| Pitch | Range | 40 | `_compare_pitch` | 40 (fine) | +| Formants | F1/F2/F3 (DTW) | 100 | `_compare_formants` | 200–300 | +| Intensity | Contour (DTW) | 10 | `_compare_intensity` | 15–20 | +| Intensity | Mean | 5 | `_compare_intensity` | 10–15 | +| Duration | Time ratio | 0.3 | `_compare_duration` | 0.3 (fine) | +| Duration | Voiced fraction | 0.2 | `_compare_duration` | 0.2 (fine) | +| Voice quality | Jitter | 0.01 | `_compare_voice_quality` | 0.02–0.03 | +| Voice quality | Shimmer | 0.05 | `_compare_voice_quality` | 0.08–0.10 | ### C. Sub-feature weights diff --git a/backend/app/routes/audio.py b/backend/app/routes/audio.py index 140afca..4293926 100644 --- a/backend/app/routes/audio.py +++ b/backend/app/routes/audio.py @@ -23,7 +23,16 @@ async def stream_audio(word_id: int, db: aiosqlite.Connection = Depends(get_db)) if not audio_file: raise HTTPException(404, f"No audio file for word {word_id}") - path = settings.AUDIO_DIR / audio_file + # Resolve path safely and ensure it stays within the configured audio directory + try: + base_dir = settings.AUDIO_DIR.resolve() + path = (base_dir / audio_file).resolve() + except Exception: + # Any failure resolving the path is treated as an invalid path + raise HTTPException(400, "Invalid audio file path") + # Prevent path traversal by ensuring the resolved path is inside base_dir + if path == base_dir or base_dir not in path.parents: + raise HTTPException(400, "Invalid audio file path") if not path.is_file(): raise HTTPException(404, f"Audio file not found on disk: {audio_file}") diff --git a/backend/app/routes/words.py b/backend/app/routes/words.py index 04297d4..fb00917 100644 --- a/backend/app/routes/words.py +++ b/backend/app/routes/words.py @@ -8,8 +8,6 @@ router = APIRouter(tags=["words"]) -LANG_COLUMNS = {"en": "translation_en", "fr": "translation_fr", "de": "translation_de"} - @router.get("/categories/{category_name}/words", response_model=list[WordOut]) async def list_words( @@ -25,15 +23,23 @@ async def list_words( if not cat: raise HTTPException(404, f"Category '{category_name}' not found") - col = LANG_COLUMNS[lang] cursor = await db.execute( - f""" - SELECT id, word_lb, {col} AS translation, gender, audio_filename + """ + SELECT + id, + word_lb, + CASE ? + WHEN 'en' THEN translation_en + WHEN 'fr' THEN translation_fr + WHEN 'de' THEN translation_de + END AS translation, + gender, + audio_filename FROM words WHERE category_id = ? ORDER BY id """, - (cat["id"],), + (lang, cat["id"]), ) rows = await cursor.fetchall() return [ diff --git a/topic.js b/topic.js index 63b45db..8a572b7 100644 --- a/topic.js +++ b/topic.js @@ -97,7 +97,7 @@ function updateUI() { const labels = { en: "English", fr: "French", de: "German" }; const label = labels[TARGET_LANG] || "English"; const translation = word.translation || ""; - meaningText.innerHTML = `Translation (${label}): ${translation}`; + meaningText.textContent = `Translation (${label}): ${translation}`; } // Audio-only mode hides word + meaning