feat(tts): integrate autonomous Piper neural TTS with zero-latency pre-warming for words and context
This commit is contained in:
1 parent
915d2784a2
commit
a6fb4349e9
12 files changed
+512
-25
No files matched your search
@@ -14,6 +14,8 @@ ENV/
|
|||||||
*.db-wal
|
*.db-wal
|
||||||
*.db-shm
|
*.db-shm
|
||||||
data/ollama/
|
data/ollama/
|
||||||
|
data/piper/
|
||||||
|
data/audio_cache/
|
||||||
data/*.db
|
data/*.db
|
||||||
data/*.db-wal
|
data/*.db-wal
|
||||||
data/*.db-shm
|
data/*.db-shm
|
||||||
|
|||||||
@@ -402,13 +402,21 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 18. 🎧 Автономная нейро-озвучка Piper TTS и аудирование (Низкий приоритет, в конце)
|
### 18. 🎧 Автономная нейро-озвучка Piper TTS и аудирование
|
||||||
- [ ] Подключение локального C++ движка Piper TTS (VITS, 100% оффлайн, ~150 МБ RAM, CPU RTF 0.2).
|
- [x] **Локальный движок Piper TTS**:
|
||||||
- [ ] Компактные модели дикторов (EN `en_US-lessac` + RU `ru_RU-dmitri`).
|
- Автономный VITS-инференс, 100% оффлайн, ~120 МБ RAM, сверхбыстрый отклик на AMD Ryzen 9 5950X (RTF ~0.11).
|
||||||
- [ ] Регулировка скорости воспроизведения (0.75x для разбора сложных звуков, 1.0x, 1.25x для тренировки беглой речи).
|
- [x] **Качественные модели дикторов**:
|
||||||
- [ ] Оффлайн-кэширование аудио предложений в `data/audio_cache/` со сжатием в Opus и автоочисткой по TTL 7 дней (Zero Bloat).
|
- Английский: `en_US-lessac` (чёткий нативный американский диктор).
|
||||||
- [ ] Фоновый экспорт главы в аудиокнигу для дороги и прогулок.
|
- Русский: `ru_RU-dmitri` (естественный русский голос).
|
||||||
- [ ] Интерактивный Shadowing (прослушивание $\rightarrow$ повтор в микрофон $\rightarrow$ клиентская проверка).
|
- [x] **Проактивное фоновое пред-кэширование (Zero Latency)**:
|
||||||
|
- При сохранении карточки в словарь (`POST /api/cards`) бэкенд в фоне синтезирует слово и всё предложение-контекст.
|
||||||
|
- В читалке при переводе предложения (`POST /api/translate-sentence`) аудио фразы пред-кэшируется в фоне.
|
||||||
|
- [x] **Интеграция в интерфейс**:
|
||||||
|
- Карточки SRS (лицевая сторона 🔊, обратная сторона 🔊, и отдельная кнопка озвучки контекста 🔊).
|
||||||
|
- Викторина SRS (кнопка 🔊 озвучки слова).
|
||||||
|
- Читалка (кнопка 🔊 у слова в попапе, 🔊 у всего предложения, и 🔊 RU для русского перевода).
|
||||||
|
- Словарь (кнопка 🔊 в результатах поиска).
|
||||||
|
- [ ] **Второй этап**: интерактивный тренажёр аудирования (Listening & Q&A) с вопросами от нейросети и проверкой понимания на слух.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+109
-5
@@ -1,4 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import json
|
import json
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -6,7 +7,7 @@ from contextlib import asynccontextmanager
|
|||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, List, Dict, Any
|
||||||
from datetime import datetime, timezone, date
|
from datetime import datetime, timezone, date
|
||||||
from fastapi import FastAPI, Depends, HTTPException, Request, Response, status, Query
|
from fastapi import FastAPI, Depends, HTTPException, Request, Response, status, Query
|
||||||
from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse, FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.middleware.gzip import GZipMiddleware
|
from fastapi.middleware.gzip import GZipMiddleware
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
@@ -59,6 +60,13 @@ from app.tasks import (
|
|||||||
cancel_task,
|
cancel_task,
|
||||||
)
|
)
|
||||||
from app.miner import run_mining_task_background
|
from app.miner import run_mining_task_background
|
||||||
|
from app.tts import (
|
||||||
|
synthesize_speech,
|
||||||
|
get_audio_hash,
|
||||||
|
prewarm_card_audio,
|
||||||
|
AUDIO_CACHE_DIR,
|
||||||
|
is_piper_available,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -460,6 +468,10 @@ async def api_translate_sentence(
|
|||||||
clean_s = req.sentence.strip()
|
clean_s = req.sentence.strip()
|
||||||
if not clean_s:
|
if not clean_s:
|
||||||
raise HTTPException(status_code=400, detail="Sentence cannot be empty")
|
raise HTTPException(status_code=400, detail="Sentence cannot be empty")
|
||||||
|
|
||||||
|
# Pre-warm neural audio for sentence and target word in background
|
||||||
|
asyncio.create_task(prewarm_card_audio(req.target_word or "", clean_s))
|
||||||
|
|
||||||
return await translate_sentence_ai(clean_s, req.target_word or "", req.context or "")
|
return await translate_sentence_ai(clean_s, req.target_word or "", req.context or "")
|
||||||
|
|
||||||
|
|
||||||
@@ -471,6 +483,63 @@ async def api_ai_status(user: dict = Depends(require_user)):
|
|||||||
return await get_ollama_status()
|
return await get_ollama_status()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Autonomous Neural TTS & Audio Streaming (Task 18)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@app.get("/api/audio/{hash_val}.wav")
|
||||||
|
async def api_get_cached_audio(hash_val: str):
|
||||||
|
"""
|
||||||
|
Streams pre-warmed / cached audio file directly with immutable cache headers.
|
||||||
|
"""
|
||||||
|
clean_hash = hash_val.lower().strip()
|
||||||
|
if not re.match(r"^[a-f0-9]{32}$", clean_hash):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid audio hash")
|
||||||
|
audio_path = AUDIO_CACHE_DIR / f"{clean_hash}.wav"
|
||||||
|
if not audio_path.is_file():
|
||||||
|
raise HTTPException(status_code=404, detail="Audio file not found")
|
||||||
|
return FileResponse(
|
||||||
|
path=str(audio_path),
|
||||||
|
media_type="audio/wav",
|
||||||
|
headers={"Cache-Control": "public, max-age=31536000, immutable"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/tts")
|
||||||
|
async def api_tts(
|
||||||
|
text: str = Query(..., min_length=1, max_length=1000),
|
||||||
|
lang: str = Query("en"),
|
||||||
|
speed: float = Query(1.0, ge=0.5, le=2.0),
|
||||||
|
user: dict = Depends(require_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Synthesizes speech on-demand using local Piper TTS (en_US-lessac / ru_RU-dmitri).
|
||||||
|
Instantly returns cached audio if previously synthesized.
|
||||||
|
"""
|
||||||
|
clean_text = text.strip()
|
||||||
|
if not clean_text:
|
||||||
|
raise HTTPException(status_code=400, detail="Text cannot be empty")
|
||||||
|
audio_path = await synthesize_speech(clean_text, lang=lang, speed=speed)
|
||||||
|
if not audio_path or not audio_path.is_file():
|
||||||
|
raise HTTPException(status_code=500, detail="TTS synthesis failed or unavailable")
|
||||||
|
return FileResponse(
|
||||||
|
path=str(audio_path),
|
||||||
|
media_type="audio/wav",
|
||||||
|
headers={"Cache-Control": "public, max-age=604800, immutable"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/tts/status")
|
||||||
|
async def api_tts_status(user: dict = Depends(require_user)):
|
||||||
|
"""Returns status of local Piper TTS and available voice models."""
|
||||||
|
return {
|
||||||
|
"available": is_piper_available(),
|
||||||
|
"engine": "piper-vits",
|
||||||
|
"voices": ["en_US-lessac", "ru_RU-dmitri"],
|
||||||
|
"cache_dir": str(AUDIO_CACHE_DIR),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Collections
|
# Collections
|
||||||
@@ -557,7 +626,14 @@ async def api_get_cards(
|
|||||||
params.extend([limit, offset])
|
params.extend([limit, offset])
|
||||||
|
|
||||||
rows = conn.execute(query, params).fetchall()
|
rows = conn.execute(query, params).fetchall()
|
||||||
cards = [dict(r) for r in rows]
|
cards = []
|
||||||
|
for r in rows:
|
||||||
|
c = dict(r)
|
||||||
|
if not c.get("audio_url"):
|
||||||
|
c["audio_url"] = f"/api/audio/{get_audio_hash(c['term'], 'en', 1.0)}.wav"
|
||||||
|
if c.get("context_sentence"):
|
||||||
|
c["context_audio_url"] = f"/api/audio/{get_audio_hash(c['context_sentence'], 'en', 1.0)}.wav"
|
||||||
|
cards.append(c)
|
||||||
|
|
||||||
return {"total": total, "cards": cards}
|
return {"total": total, "cards": cards}
|
||||||
|
|
||||||
@@ -597,9 +673,20 @@ async def api_create_card(card: CardCreateRequest, user: dict = Depends(require_
|
|||||||
)
|
)
|
||||||
updated_row = conn.execute("SELECT * FROM vocabulary_cards WHERE id = ?", (existing_row["id"],)).fetchone()
|
updated_row = conn.execute("SELECT * FROM vocabulary_cards WHERE id = ?", (existing_row["id"],)).fetchone()
|
||||||
res = dict(updated_row)
|
res = dict(updated_row)
|
||||||
|
if not res.get("audio_url"):
|
||||||
|
res["audio_url"] = f"/api/audio/{get_audio_hash(res['term'], 'en', 1.0)}.wav"
|
||||||
|
if res.get("context_sentence"):
|
||||||
|
res["context_audio_url"] = f"/api/audio/{get_audio_hash(res['context_sentence'], 'en', 1.0)}.wav"
|
||||||
res["already_exists"] = True
|
res["already_exists"] = True
|
||||||
|
|
||||||
|
# Pre-warm word and context audio in background
|
||||||
|
asyncio.create_task(prewarm_card_audio(clean_term, card.context_sentence))
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
audio_url_val = (card.audio_url or "").strip()
|
||||||
|
if not audio_url_val:
|
||||||
|
audio_url_val = f"/api/audio/{get_audio_hash(clean_term, 'en', 1.0)}.wav"
|
||||||
|
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO vocabulary_cards (
|
INSERT INTO vocabulary_cards (
|
||||||
@@ -614,7 +701,7 @@ async def api_create_card(card: CardCreateRequest, user: dict = Depends(require_
|
|||||||
card.translation.strip(),
|
card.translation.strip(),
|
||||||
(card.context_sentence or "").strip(),
|
(card.context_sentence or "").strip(),
|
||||||
(card.phonetic or "").strip(),
|
(card.phonetic or "").strip(),
|
||||||
(card.audio_url or "").strip(),
|
audio_url_val,
|
||||||
(card.part_of_speech or "").strip(),
|
(card.part_of_speech or "").strip(),
|
||||||
(card.definition_en or "").strip(),
|
(card.definition_en or "").strip(),
|
||||||
(card.tags or "general").strip(),
|
(card.tags or "general").strip(),
|
||||||
@@ -624,7 +711,15 @@ async def api_create_card(card: CardCreateRequest, user: dict = Depends(require_
|
|||||||
)
|
)
|
||||||
new_id = cursor.lastrowid
|
new_id = cursor.lastrowid
|
||||||
row = conn.execute("SELECT * FROM vocabulary_cards WHERE id = ?", (new_id,)).fetchone()
|
row = conn.execute("SELECT * FROM vocabulary_cards WHERE id = ?", (new_id,)).fetchone()
|
||||||
return dict(row)
|
res = dict(row)
|
||||||
|
if not res.get("audio_url"):
|
||||||
|
res["audio_url"] = f"/api/audio/{get_audio_hash(res['term'], 'en', 1.0)}.wav"
|
||||||
|
if res.get("context_sentence"):
|
||||||
|
res["context_audio_url"] = f"/api/audio/{get_audio_hash(res['context_sentence'], 'en', 1.0)}.wav"
|
||||||
|
|
||||||
|
# Pre-warm word and context audio in background!
|
||||||
|
asyncio.create_task(prewarm_card_audio(clean_term, card.context_sentence))
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -835,7 +930,16 @@ async def api_srs_queue(
|
|||||||
rows = conn.execute(query, params).fetchall()
|
rows = conn.execute(query, params).fetchall()
|
||||||
cards = [dict(r) for r in rows]
|
cards = [dict(r) for r in rows]
|
||||||
|
|
||||||
return {"count": len(cards), "cards": cards}
|
enhanced_cards = []
|
||||||
|
for c in cards:
|
||||||
|
cd = dict(c)
|
||||||
|
if not cd.get("audio_url"):
|
||||||
|
cd["audio_url"] = f"/api/audio/{get_audio_hash(cd['term'], 'en', 1.0)}.wav"
|
||||||
|
if cd.get("context_sentence"):
|
||||||
|
cd["context_audio_url"] = f"/api/audio/{get_audio_hash(cd['context_sentence'], 'en', 1.0)}.wav"
|
||||||
|
enhanced_cards.append(cd)
|
||||||
|
|
||||||
|
return {"count": len(enhanced_cards), "cards": enhanced_cards}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/srs/quiz")
|
@app.get("/api/srs/quiz")
|
||||||
|
|||||||
@@ -905,6 +905,37 @@ body.drawer-open {
|
|||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.audio-xs-btn {
|
||||||
|
background-color: var(--primary-dim);
|
||||||
|
border: 1px solid var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-xs-btn:hover {
|
||||||
|
background-color: var(--primary);
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-sentence-audio-btn {
|
||||||
|
border-color: var(--border-color);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-sentence-audio-btn:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
background-color: var(--primary-dim);
|
||||||
|
}
|
||||||
|
|
||||||
.definition-box {
|
.definition-box {
|
||||||
background-color: var(--bg-main);
|
background-color: var(--bg-main);
|
||||||
border-left: 3px solid var(--primary);
|
border-left: 3px solid var(--primary);
|
||||||
|
|||||||
+11
-2
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
<title>Notes — Персональный тренажер и словарь английского языка</title>
|
<title>Notes — Персональный тренажер и словарь английского языка</title>
|
||||||
<meta name="description" content="Защищённый персональный тренажёр и словарь английского языка с интервальными повторениями SRS SM-2, контекстным поиском и озвучкой">
|
<meta name="description" content="Защищённый персональный тренажёр и словарь английского языка с интервальными повторениями SRS SM-2, контекстным поиском и озвучкой">
|
||||||
<link rel="stylesheet" href="/static/css/style.css?v=5.5">
|
<link rel="stylesheet" href="/static/css/style.css?v=6.4">
|
||||||
|
|
||||||
<!-- PWA meta and icons -->
|
<!-- PWA meta and icons -->
|
||||||
<link rel="manifest" href="/manifest.json">
|
<link rel="manifest" href="/manifest.json">
|
||||||
@@ -319,7 +319,10 @@
|
|||||||
<p id="fc-back-def" class="detail-text"></p>
|
<p id="fc-back-def" class="detail-text"></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="fc-back-detail-box" id="fc-back-context-box">
|
<div class="fc-back-detail-box" id="fc-back-context-box">
|
||||||
|
<div class="fc-detail-header-row" style="display: flex; align-items: center; justify-content: space-between;">
|
||||||
<span class="detail-label">Контекст:</span>
|
<span class="detail-label">Контекст:</span>
|
||||||
|
<button type="button" id="fc-back-context-audio-btn" class="icon-btn audio-xs-btn" title="Озвучить предложение контекста">🔊</button>
|
||||||
|
</div>
|
||||||
<p id="fc-back-context" class="detail-text"></p>
|
<p id="fc-back-context" class="detail-text"></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="fc-back-detail-box" id="fc-back-notes-box">
|
<div class="fc-back-detail-box" id="fc-back-notes-box">
|
||||||
@@ -1671,13 +1674,19 @@
|
|||||||
<button type="button" id="popup-translate-sentence-btn" class="btn btn-xs btn-outline popup-sentence-btn" title="Перевести всё предложение через локальный ИИ">
|
<button type="button" id="popup-translate-sentence-btn" class="btn btn-xs btn-outline popup-sentence-btn" title="Перевести всё предложение через локальный ИИ">
|
||||||
🌐 Перевести предложение
|
🌐 Перевести предложение
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" id="popup-audio-sentence-btn" class="btn btn-xs btn-outline popup-sentence-audio-btn" title="Озвучить всё предложение нейросетью">
|
||||||
|
🔊 Озвучить
|
||||||
|
</button>
|
||||||
<span id="popup-ai-source-badge" class="popup-ai-source-badge hidden">⚡ Local AI</span>
|
<span id="popup-ai-source-badge" class="popup-ai-source-badge hidden">⚡ Local AI</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="popup-sentence-loading" class="popup-sentence-loading hidden">
|
<div id="popup-sentence-loading" class="popup-sentence-loading hidden">
|
||||||
<span class="spinner-sm"></span> Локальный ИИ переводит...
|
<span class="spinner-sm"></span> Локальный ИИ переводит...
|
||||||
</div>
|
</div>
|
||||||
<div id="popup-sentence-translation-wrap" class="popup-sentence-translation-wrap hidden">
|
<div id="popup-sentence-translation-wrap" class="popup-sentence-translation-wrap hidden">
|
||||||
|
<div class="popup-sentence-ru-row" style="display: flex; align-items: flex-start; justify-content: space-between; gap: 8px;">
|
||||||
<div class="popup-sentence-ru" id="popup-sentence-ru"></div>
|
<div class="popup-sentence-ru" id="popup-sentence-ru"></div>
|
||||||
|
<button type="button" id="popup-audio-ru-btn" class="icon-btn audio-xs-btn" style="flex-shrink: 0;" title="Озвучить русский перевод">🔊 RU</button>
|
||||||
|
</div>
|
||||||
<div id="popup-context-meaning-wrap" class="popup-context-meaning-wrap hidden">
|
<div id="popup-context-meaning-wrap" class="popup-context-meaning-wrap hidden">
|
||||||
<span class="context-meaning-label">🎯 В контексте:</span>
|
<span class="context-meaning-label">🎯 В контексте:</span>
|
||||||
<strong id="popup-context-meaning-val" class="context-meaning-val"></strong>
|
<strong id="popup-context-meaning-val" class="context-meaning-val"></strong>
|
||||||
@@ -1697,7 +1706,7 @@
|
|||||||
<!-- Audio Element for native streaming -->
|
<!-- Audio Element for native streaming -->
|
||||||
<audio id="global-audio-player" preload="none"></audio>
|
<audio id="global-audio-player" preload="none"></audio>
|
||||||
|
|
||||||
<script type="module" src="/static/js/app.js?v=5.5"></script>
|
<script type="module" src="/static/js/app.js?v=6.4"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
// =======================================================================
|
// =======================================================================
|
||||||
|
|
||||||
import { api } from './api.js';
|
import { api } from './api.js';
|
||||||
import { escapeHtml, showToast, openModal, closeModal } from './ui.js';
|
import { escapeHtml, showToast, openModal, closeModal, playAudio } from './ui.js';
|
||||||
|
|
||||||
export let allCards = [];
|
export let allCards = [];
|
||||||
export let collectionsList = ['General', 'Разговорный', 'Работа & IT', 'Книги & Сериалы', 'Идиомы'];
|
export let collectionsList = ['General', 'Разговорный', 'Работа & IT', 'Книги & Сериалы', 'Идиомы'];
|
||||||
@@ -163,6 +163,14 @@ export function initDictionary() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (audioPlayBtn) {
|
||||||
|
audioPlayBtn.addEventListener('click', () => {
|
||||||
|
if (!currentLookupData || !currentLookupData.term) return;
|
||||||
|
const url = currentLookupData.audio_url || `/api/tts?text=${encodeURIComponent(currentLookupData.term)}&lang=en`;
|
||||||
|
playAudio(url);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function performLookup() {
|
async function performLookup() {
|
||||||
const term = termInput.value.trim();
|
const term = termInput.value.trim();
|
||||||
if (!term) return;
|
if (!term) return;
|
||||||
@@ -188,7 +196,7 @@ export function initDictionary() {
|
|||||||
resPhonetic.textContent = data.phonetic || '';
|
resPhonetic.textContent = data.phonetic || '';
|
||||||
resPhonetic.style.display = data.phonetic ? 'inline-block' : 'none';
|
resPhonetic.style.display = data.phonetic ? 'inline-block' : 'none';
|
||||||
resPos.textContent = data.part_of_speech || 'Phrase';
|
resPos.textContent = data.part_of_speech || 'Phrase';
|
||||||
audioPlayBtn.classList.add('hidden');
|
if (audioPlayBtn) audioPlayBtn.classList.remove('hidden');
|
||||||
|
|
||||||
if (data.definition_en) {
|
if (data.definition_en) {
|
||||||
resDefinition.textContent = data.definition_en;
|
resDefinition.textContent = data.definition_en;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// =======================================================================
|
// =======================================================================
|
||||||
|
|
||||||
import { api } from './api.js';
|
import { api } from './api.js';
|
||||||
import { escapeHtml, escapeRegex, slugify, showToast } from './ui.js';
|
import { escapeHtml, escapeRegex, slugify, showToast, playAudio } from './ui.js';
|
||||||
import { getAllCards, fetchCards } from './dictionary.js';
|
import { getAllCards, fetchCards } from './dictionary.js';
|
||||||
|
|
||||||
// Reader State & Appearance Settings
|
// Reader State & Appearance Settings
|
||||||
@@ -1454,7 +1454,11 @@ export async function openWordPopup(term, contextSentence, targetElement, paragr
|
|||||||
if (popupTranslation) popupTranslation.textContent = trans || 'Перевод не найден';
|
if (popupTranslation) popupTranslation.textContent = trans || 'Перевод не найден';
|
||||||
if (popupLoading) popupLoading.classList.add('hidden');
|
if (popupLoading) popupLoading.classList.add('hidden');
|
||||||
if (popupContent) popupContent.classList.remove('hidden');
|
if (popupContent) popupContent.classList.remove('hidden');
|
||||||
if (popupAudioBtn) popupAudioBtn.classList.add('hidden');
|
if (popupAudioBtn) {
|
||||||
|
popupAudioBtn.classList.remove('hidden');
|
||||||
|
popupAudioBtn.style.display = 'inline-flex';
|
||||||
|
}
|
||||||
|
popupCurrentData.audioUrl = data.audio_url || '';
|
||||||
|
|
||||||
const inLib = Boolean(data.in_library || existingCard);
|
const inLib = Boolean(data.in_library || existingCard);
|
||||||
const libCard = data.library_card || existingCard;
|
const libCard = data.library_card || existingCard;
|
||||||
@@ -2096,6 +2100,9 @@ export function initReader() {
|
|||||||
window.addEventListener('scroll', schedulePopupUpdate, { passive: true, capture: true });
|
window.addEventListener('scroll', schedulePopupUpdate, { passive: true, capture: true });
|
||||||
window.addEventListener('resize', schedulePopupUpdate, { passive: true });
|
window.addEventListener('resize', schedulePopupUpdate, { passive: true });
|
||||||
|
|
||||||
|
const popupAudioBtn = document.getElementById('popup-audio-btn');
|
||||||
|
const popupAudioSentenceBtn = document.getElementById('popup-audio-sentence-btn');
|
||||||
|
const popupAudioRuBtn = document.getElementById('popup-audio-ru-btn');
|
||||||
const popupTranslateSentenceBtn = document.getElementById('popup-translate-sentence-btn');
|
const popupTranslateSentenceBtn = document.getElementById('popup-translate-sentence-btn');
|
||||||
const popupUseContextMeaningBtn = document.getElementById('popup-use-context-meaning-btn');
|
const popupUseContextMeaningBtn = document.getElementById('popup-use-context-meaning-btn');
|
||||||
const popupSentenceLoading = document.getElementById('popup-sentence-loading');
|
const popupSentenceLoading = document.getElementById('popup-sentence-loading');
|
||||||
@@ -2105,6 +2112,36 @@ export function initReader() {
|
|||||||
const popupContextMeaningVal = document.getElementById('popup-context-meaning-val');
|
const popupContextMeaningVal = document.getElementById('popup-context-meaning-val');
|
||||||
const popupAiSourceBadge = document.getElementById('popup-ai-source-badge');
|
const popupAiSourceBadge = document.getElementById('popup-ai-source-badge');
|
||||||
|
|
||||||
|
if (popupAudioBtn) {
|
||||||
|
popupAudioBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!popupCurrentData || !popupCurrentData.term) return;
|
||||||
|
const term = popupCurrentData.term;
|
||||||
|
const url = popupCurrentData.audioUrl || `/api/tts?text=${encodeURIComponent(term)}&lang=en`;
|
||||||
|
playAudio(url);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (popupAudioSentenceBtn) {
|
||||||
|
popupAudioSentenceBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!popupCurrentData || !popupCurrentData.contextSentence) return;
|
||||||
|
const sentence = popupCurrentData.contextSentence;
|
||||||
|
const url = `/api/tts?text=${encodeURIComponent(sentence)}&lang=en`;
|
||||||
|
playAudio(url);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (popupAudioRuBtn) {
|
||||||
|
popupAudioRuBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const ruText = (popupSentenceRu && popupSentenceRu.textContent || '').trim();
|
||||||
|
if (!ruText || ruText.startsWith('(') || ruText.startsWith('Ошибка')) return;
|
||||||
|
const url = `/api/tts?text=${encodeURIComponent(ruText)}&lang=ru`;
|
||||||
|
playAudio(url);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (popupTranslateSentenceBtn) {
|
if (popupTranslateSentenceBtn) {
|
||||||
popupTranslateSentenceBtn.addEventListener('click', async (e) => {
|
popupTranslateSentenceBtn.addEventListener('click', async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export function initSrs() {
|
|||||||
const fcBackDef = document.getElementById('fc-back-def');
|
const fcBackDef = document.getElementById('fc-back-def');
|
||||||
const fcBackContextBox = document.getElementById('fc-back-context-box');
|
const fcBackContextBox = document.getElementById('fc-back-context-box');
|
||||||
const fcBackContext = document.getElementById('fc-back-context');
|
const fcBackContext = document.getElementById('fc-back-context');
|
||||||
|
const fcBackContextAudioBtn = document.getElementById('fc-back-context-audio-btn');
|
||||||
const fcBackNotesBox = document.getElementById('fc-back-notes-box');
|
const fcBackNotesBox = document.getElementById('fc-back-notes-box');
|
||||||
const fcBackNotes = document.getElementById('fc-back-notes');
|
const fcBackNotes = document.getElementById('fc-back-notes');
|
||||||
const fcFlipBtnWrap = document.getElementById('fc-flip-btn-wrap');
|
const fcFlipBtnWrap = document.getElementById('fc-flip-btn-wrap');
|
||||||
@@ -298,8 +299,41 @@ export function initSrs() {
|
|||||||
fcBackNotes.textContent = card.notes || '';
|
fcBackNotes.textContent = card.notes || '';
|
||||||
fcBackNotesBox.style.display = card.notes ? 'block' : 'none';
|
fcBackNotesBox.style.display = card.notes ? 'block' : 'none';
|
||||||
|
|
||||||
fcFrontAudioBtn.style.display = 'none';
|
if (fcFrontAudioBtn) fcFrontAudioBtn.style.display = 'inline-flex';
|
||||||
fcBackAudioBtn.style.display = 'none';
|
if (fcBackAudioBtn) fcBackAudioBtn.style.display = 'inline-flex';
|
||||||
|
if (fcBackContextAudioBtn) {
|
||||||
|
fcBackContextAudioBtn.style.display = card.context_sentence ? 'inline-flex' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fcFrontAudioBtn) {
|
||||||
|
fcFrontAudioBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const card = srsQueue[srsCurrentIndex];
|
||||||
|
if (!card) return;
|
||||||
|
const url = card.audio_url || `/api/tts?text=${encodeURIComponent(card.term)}&lang=en`;
|
||||||
|
playAudio(url);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fcBackAudioBtn) {
|
||||||
|
fcBackAudioBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const card = srsQueue[srsCurrentIndex];
|
||||||
|
if (!card) return;
|
||||||
|
const url = card.audio_url || `/api/tts?text=${encodeURIComponent(card.term)}&lang=en`;
|
||||||
|
playAudio(url);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fcBackContextAudioBtn) {
|
||||||
|
fcBackContextAudioBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const card = srsQueue[srsCurrentIndex];
|
||||||
|
if (!card || !card.context_sentence) return;
|
||||||
|
const url = card.context_audio_url || `/api/tts?text=${encodeURIComponent(card.context_sentence)}&lang=en`;
|
||||||
|
playAudio(url);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function flipFlashcard() {
|
function flipFlashcard() {
|
||||||
@@ -363,7 +397,7 @@ export function initSrs() {
|
|||||||
quizContextHint.classList.add('hidden');
|
quizContextHint.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
quizAudioBtn.classList.add('hidden');
|
if (quizAudioBtn) quizAudioBtn.classList.remove('hidden');
|
||||||
|
|
||||||
const correctText = card.translation;
|
const correctText = card.translation;
|
||||||
const all = getAllCards();
|
const all = getAllCards();
|
||||||
@@ -436,6 +470,16 @@ export function initSrs() {
|
|||||||
quizNextBtn.focus();
|
quizNextBtn.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (quizAudioBtn) {
|
||||||
|
quizAudioBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const card = srsQueue[srsCurrentIndex];
|
||||||
|
if (!card) return;
|
||||||
|
const url = card.audio_url || `/api/tts?text=${encodeURIComponent(card.term)}&lang=en`;
|
||||||
|
playAudio(url);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
quizNextBtn.addEventListener('click', () => {
|
quizNextBtn.addEventListener('click', () => {
|
||||||
srsCurrentIndex++;
|
srsCurrentIndex++;
|
||||||
renderSrsCurrentCard();
|
renderSrsCurrentCard();
|
||||||
|
|||||||
@@ -168,14 +168,34 @@ export function updateHeaderStats(total = 0, today = 0, srsCount = 0) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Audio Player Helper (Safe stub / audio stream)
|
// Audio Player Helper (Safe stub / audio stream / Piper TTS)
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
export function playAudio(url) {
|
let activeAudio = null;
|
||||||
if (!url) return;
|
|
||||||
|
export function playAudio(url, speed = 1.0) {
|
||||||
|
if (!url) return null;
|
||||||
try {
|
try {
|
||||||
|
if (activeAudio) {
|
||||||
|
try {
|
||||||
|
activeAudio.pause();
|
||||||
|
activeAudio.currentTime = 0;
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
const audio = new Audio(url);
|
const audio = new Audio(url);
|
||||||
|
if (speed && speed !== 1.0) {
|
||||||
|
audio.playbackRate = speed;
|
||||||
|
}
|
||||||
|
activeAudio = audio;
|
||||||
audio.play().catch(e => console.warn('Audio playback not supported or blocked:', e));
|
audio.play().catch(e => console.warn('Audio playback not supported or blocked:', e));
|
||||||
|
return audio;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Audio play error:', e);
|
console.warn('Audio play error:', e);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function playTextSpeech(text, lang = 'en', speed = 1.0) {
|
||||||
|
if (!text || !text.trim()) return null;
|
||||||
|
const url = `/api/tts?text=${encodeURIComponent(text.trim())}&lang=${lang}&speed=${speed}`;
|
||||||
|
return playAudio(url, speed);
|
||||||
|
}
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
// Service Worker for Notes English PWA (Stage 2)
|
// Service Worker for Notes English PWA (Stage 2)
|
||||||
// Strategy: Cache-first for static assets, Network-first for API calls.
|
// Strategy: Cache-first for static assets, Network-first for API calls.
|
||||||
|
|
||||||
const CACHE_NAME = 'notes-v6.3';
|
const CACHE_NAME = 'notes-v6.4';
|
||||||
const OFFLINE_URL = '/';
|
const OFFLINE_URL = '/';
|
||||||
|
|
||||||
const PRECACHE_URLS = [
|
const PRECACHE_URLS = [
|
||||||
|
|||||||
+174
@@ -0,0 +1,174 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
import hashlib
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
|
||||||
|
from app import config
|
||||||
|
|
||||||
|
logger = logging.getLogger("notes_app.tts")
|
||||||
|
|
||||||
|
PIPER_DIR = config.BASE_DIR / "data" / "piper"
|
||||||
|
PIPER_BIN = PIPER_DIR / "piper"
|
||||||
|
MODELS_DIR = PIPER_DIR / "models"
|
||||||
|
AUDIO_CACHE_DIR = config.BASE_DIR / "data" / "audio_cache"
|
||||||
|
|
||||||
|
# Ensure audio cache directory exists
|
||||||
|
AUDIO_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
VOICE_MODELS = {
|
||||||
|
"en": {
|
||||||
|
"model": MODELS_DIR / "en_US-lessac-medium.onnx",
|
||||||
|
"config": MODELS_DIR / "en_US-lessac-medium.onnx.json",
|
||||||
|
"name": "en_US-lessac",
|
||||||
|
},
|
||||||
|
"ru": {
|
||||||
|
"model": MODELS_DIR / "ru_RU-dmitri-medium.onnx",
|
||||||
|
"config": MODELS_DIR / "ru_RU-dmitri-medium.onnx.json",
|
||||||
|
"name": "ru_RU-dmitri",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def clean_tts_text(text: str) -> str:
|
||||||
|
"""Cleans input text from markdown, HTML tags, and abnormal whitespace."""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
# Remove HTML tags if any
|
||||||
|
t = re.sub(r"<[^>]+>", " ", text)
|
||||||
|
# Remove markdown link formatting [text](url) -> text
|
||||||
|
t = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", t)
|
||||||
|
# Collapse multiple whitespaces
|
||||||
|
t = " ".join(t.strip().split())
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
def get_audio_hash(text: str, lang: str = "en", speed: float = 1.0) -> str:
|
||||||
|
"""Computes a deterministic MD5 hash for audio caching."""
|
||||||
|
clean = clean_tts_text(text).lower()
|
||||||
|
norm_speed = round(float(speed), 2)
|
||||||
|
norm_lang = "ru" if lang.lower().startswith("ru") else "en"
|
||||||
|
key = f"{norm_lang}:{norm_speed:.2f}:{clean}".encode("utf-8")
|
||||||
|
return hashlib.md5(key).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def get_cached_audio_path(text: str, lang: str = "en", speed: float = 1.0) -> Optional[Path]:
|
||||||
|
"""Returns Path if already cached and valid, else None."""
|
||||||
|
h = get_audio_hash(text, lang, speed)
|
||||||
|
file_path = AUDIO_CACHE_DIR / f"{h}.wav"
|
||||||
|
if file_path.is_file() and file_path.stat().st_size > 100:
|
||||||
|
return file_path
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_piper_available() -> bool:
|
||||||
|
"""Checks if piper binary and at least en model exist and are executable."""
|
||||||
|
if not PIPER_BIN.is_file() or not os.access(PIPER_BIN, os.X_OK):
|
||||||
|
return False
|
||||||
|
en_model = VOICE_MODELS["en"]["model"]
|
||||||
|
return en_model.is_file()
|
||||||
|
|
||||||
|
|
||||||
|
async def synthesize_speech(
|
||||||
|
text: str,
|
||||||
|
lang: str = "en",
|
||||||
|
speed: float = 1.0,
|
||||||
|
) -> Optional[Path]:
|
||||||
|
"""
|
||||||
|
Synthesizes speech using local Piper TTS and returns Path to the generated WAV file.
|
||||||
|
Fast disk-cached: if already generated, returns immediately without running synthesis.
|
||||||
|
"""
|
||||||
|
clean = clean_tts_text(text)
|
||||||
|
if not clean:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check cache first
|
||||||
|
cached_path = get_cached_audio_path(clean, lang, speed)
|
||||||
|
if cached_path:
|
||||||
|
return cached_path
|
||||||
|
|
||||||
|
if not is_piper_available():
|
||||||
|
logger.warning("Piper TTS binary or models not available")
|
||||||
|
return None
|
||||||
|
|
||||||
|
norm_lang = "ru" if lang.lower().startswith("ru") else "en"
|
||||||
|
voice_info = VOICE_MODELS.get(norm_lang, VOICE_MODELS["en"])
|
||||||
|
model_path = voice_info["model"]
|
||||||
|
config_path = voice_info["config"]
|
||||||
|
|
||||||
|
if not model_path.is_file() or not config_path.is_file():
|
||||||
|
logger.warning(f"Voice model files missing for {norm_lang}: {model_path}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
h = get_audio_hash(clean, norm_lang, speed)
|
||||||
|
out_wav = AUDIO_CACHE_DIR / f"{h}.wav"
|
||||||
|
temp_wav = AUDIO_CACHE_DIR / f"{h}.tmp.wav"
|
||||||
|
|
||||||
|
# Piper length_scale: > 1.0 is slower, < 1.0 is faster
|
||||||
|
# 0.75x speed -> length_scale ~ 1.33
|
||||||
|
# 1.0x speed -> length_scale = 1.0
|
||||||
|
# 1.25x speed -> length_scale ~ 0.8
|
||||||
|
clamped_speed = max(0.5, min(2.0, float(speed)))
|
||||||
|
length_scale = str(round(1.0 / clamped_speed, 2))
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
ld_path = str(PIPER_DIR)
|
||||||
|
if "LD_LIBRARY_PATH" in env and env["LD_LIBRARY_PATH"]:
|
||||||
|
env["LD_LIBRARY_PATH"] = f"{ld_path}:{env['LD_LIBRARY_PATH']}"
|
||||||
|
else:
|
||||||
|
env["LD_LIBRARY_PATH"] = ld_path
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
str(PIPER_BIN),
|
||||||
|
"--model", str(model_path),
|
||||||
|
"--config", str(config_path),
|
||||||
|
"--espeak_data", str(PIPER_DIR / "espeak-ng-data"),
|
||||||
|
"--length_scale", length_scale,
|
||||||
|
"--output_file", str(temp_wav),
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdin=asyncio.subprocess.PIPE,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
stdout, stderr = await proc.communicate(input=clean.encode("utf-8"))
|
||||||
|
|
||||||
|
if proc.returncode == 0 and temp_wav.is_file() and temp_wav.stat().st_size > 100:
|
||||||
|
temp_wav.replace(out_wav)
|
||||||
|
logger.info(f"Synthesized speech [{norm_lang}, speed={speed}]: {clean[:40]}... -> {out_wav.name}")
|
||||||
|
return out_wav
|
||||||
|
else:
|
||||||
|
err_text = stderr.decode("utf-8", errors="replace").strip()
|
||||||
|
logger.warning(f"Piper exited code {proc.returncode}: {err_text}")
|
||||||
|
if temp_wav.exists():
|
||||||
|
temp_wav.unlink(missing_ok=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Piper execution error: {e}")
|
||||||
|
if temp_wav.exists():
|
||||||
|
temp_wav.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def prewarm_card_audio(term: str, context: Optional[str] = None):
|
||||||
|
"""
|
||||||
|
Background pre-synthesis of word and context sentence.
|
||||||
|
Invoked when a user saves a card to ensure zero-latency audio playback in SRS.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if term and term.strip():
|
||||||
|
await synthesize_speech(term.strip(), lang="en", speed=1.0)
|
||||||
|
if context and context.strip():
|
||||||
|
clean_ctx = context.strip().replace('"', '').strip()
|
||||||
|
# If context is very long, cap to first sentence or 300 chars
|
||||||
|
if len(clean_ctx) > 300:
|
||||||
|
clean_ctx = clean_ctx[:300]
|
||||||
|
await synthesize_speech(clean_ctx, lang="en", speed=1.0)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Background prewarm error: {e}")
|
||||||
@@ -2230,6 +2230,56 @@ def test_wikipedia_api_endpoints_workflow(monkeypatch):
|
|||||||
assert del_resp.json()["deleted_id"] == book_id
|
assert del_resp.json()["deleted_id"] == book_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_piper_tts_endpoints_and_caching():
|
||||||
|
login_resp = client.post("/api/login", json={"username": "testadmin", "password": "testpass12345"})
|
||||||
|
assert login_resp.status_code == 200
|
||||||
|
token = login_resp.cookies.get(config.SESSION_COOKIE_NAME)
|
||||||
|
auth_headers = {"Cookie": f"{config.SESSION_COOKIE_NAME}={token}"}
|
||||||
|
|
||||||
|
# 1. Test TTS Status endpoint
|
||||||
|
resp = client.get("/api/tts/status", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "available" in data
|
||||||
|
assert data["engine"] == "piper-vits"
|
||||||
|
assert "en_US-lessac" in data["voices"]
|
||||||
|
|
||||||
|
# 2. Test TTS synthesis of English word
|
||||||
|
tts_resp = client.get("/api/tts?text=Astronomy&lang=en&speed=1.0", headers=auth_headers)
|
||||||
|
assert tts_resp.status_code == 200
|
||||||
|
assert "audio/wav" in tts_resp.headers.get("content-type", "")
|
||||||
|
assert len(tts_resp.content) > 1000
|
||||||
|
|
||||||
|
# 3. Test repeat request returns cached audio
|
||||||
|
cached_resp = client.get("/api/tts?text=Astronomy&lang=en&speed=1.0", headers=auth_headers)
|
||||||
|
assert cached_resp.status_code == 200
|
||||||
|
assert len(cached_resp.content) == len(tts_resp.content)
|
||||||
|
|
||||||
|
# 4. Test cached audio streaming via /api/audio/{hash}.wav
|
||||||
|
from app.tts import get_audio_hash
|
||||||
|
h = get_audio_hash("Astronomy", "en", 1.0)
|
||||||
|
audio_file_resp = client.get(f"/api/audio/{h}.wav")
|
||||||
|
assert audio_file_resp.status_code == 200
|
||||||
|
assert "audio/wav" in audio_file_resp.headers.get("content-type", "")
|
||||||
|
assert "immutable" in audio_file_resp.headers.get("cache-control", "")
|
||||||
|
|
||||||
|
# 5. Test card creation attaches audio_url and context_audio_url
|
||||||
|
card_resp = client.post(
|
||||||
|
"/api/cards",
|
||||||
|
json={
|
||||||
|
"term": "Galileo",
|
||||||
|
"translation": "Галилей",
|
||||||
|
"context_sentence": "Galileo pointed his telescope at Jupiter.",
|
||||||
|
},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert card_resp.status_code == 200
|
||||||
|
card_data = card_resp.json()
|
||||||
|
assert "/api/audio/" in card_data["audio_url"]
|
||||||
|
assert "context_audio_url" in card_data
|
||||||
|
assert "/api/audio/" in card_data["context_audio_url"]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user