diff --git a/app/static/js/app.js b/app/static/js/app.js
index 8e5520f..8f15af4 100644
--- a/app/static/js/app.js
+++ b/app/static/js/app.js
@@ -4,7 +4,7 @@
// =======================================================================
import { api } from './modules/api.js';
-import { initTheme, openModal, closeModal, showToast } from './modules/ui.js';
+import { initTheme, openModal, closeModal, showToast, playTextSpeech } from './modules/ui.js';
import { initDictionary, loadInitialData } from './modules/dictionary.js';
import { initSrs, startSrsTrainingSession, openStatsDashboard } from './modules/srs.js';
import { initReader, loadBookshelf, openBook } from './modules/reader.js';
@@ -546,6 +546,14 @@ export function initNavigationDrawer() {
themeToggle.click();
});
}
+ const drawerNavVoiceSettings = document.getElementById('drawer-nav-voice-settings');
+ const voiceSettingsModal = document.getElementById('voice-settings-modal');
+ if (drawerNavVoiceSettings && voiceSettingsModal) {
+ drawerNavVoiceSettings.addEventListener('click', () => {
+ closeDrawer();
+ openVoiceSettingsModal();
+ });
+ }
if (drawerNavLogout && logoutBtn) {
drawerNavLogout.addEventListener('click', () => {
closeDrawer();
@@ -554,6 +562,63 @@ export function initNavigationDrawer() {
}
}
+// -----------------------------------------------------------------------
+// Voice Settings Modal Controller (TTS)
+// -----------------------------------------------------------------------
+function openVoiceSettingsModal() {
+ const voiceSettingsModal = document.getElementById('voice-settings-modal');
+ if (!voiceSettingsModal) return;
+ const currentRu = localStorage.getItem('notes_tts_voice_ru') || 'ru_RU-irina';
+ const currentEn = localStorage.getItem('notes_tts_voice_en') || 'en_US-lessac';
+ const ruInput = voiceSettingsModal.querySelector(`input[name="tts_voice_ru"][value="${currentRu}"]`);
+ if (ruInput) ruInput.checked = true;
+ const enInput = voiceSettingsModal.querySelector(`input[name="tts_voice_en"][value="${currentEn}"]`);
+ if (enInput) enInput.checked = true;
+ openModal(voiceSettingsModal);
+}
+
+function initVoiceSettingsModal() {
+ const voiceSettingsModal = document.getElementById('voice-settings-modal');
+ const voiceSettingsCloseBtn = document.getElementById('voice-settings-close-btn');
+ const voiceSettingsSaveBtn = document.getElementById('voice-settings-save-btn');
+
+ if (voiceSettingsCloseBtn && voiceSettingsModal) {
+ voiceSettingsCloseBtn.addEventListener('click', () => closeModal(voiceSettingsModal));
+ }
+
+ if (voiceSettingsSaveBtn && voiceSettingsModal) {
+ voiceSettingsSaveBtn.addEventListener('click', () => {
+ const ruVal = voiceSettingsModal.querySelector('input[name="tts_voice_ru"]:checked')?.value || 'ru_RU-irina';
+ const enVal = voiceSettingsModal.querySelector('input[name="tts_voice_en"]:checked')?.value || 'en_US-lessac';
+ localStorage.setItem('notes_tts_voice_ru', ruVal);
+ localStorage.setItem('notes_tts_voice_en', enVal);
+
+ // Sync with listening selector if on screen
+ const listeningVoiceSelect = document.getElementById('listening-voice-select');
+ if (listeningVoiceSelect) {
+ listeningVoiceSelect.value = enVal;
+ }
+
+ closeModal(voiceSettingsModal);
+ showToast('β
ΠΠΎΠ»ΠΎΡΠ° Π΄ΠΈΠΊΡΠΎΡΠΎΠ² ΡΠΎΡ
ΡΠ°Π½Π΅Π½Ρ!', 'success');
+ });
+ }
+
+ // Voice test buttons
+ if (voiceSettingsModal) {
+ voiceSettingsModal.querySelectorAll('.voice-test-btn').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ const lang = btn.dataset.lang || 'en';
+ const voice = btn.dataset.voice;
+ const text = btn.dataset.text || 'Test voice';
+ playTextSpeech(text, lang, 1.0, voice);
+ });
+ });
+ }
+}
+
// -----------------------------------------------------------------------
// Password Change Modal
// -----------------------------------------------------------------------
@@ -695,6 +760,9 @@ document.addEventListener('DOMContentLoaded', () => {
// 7. Change Password Modal
initChangePasswordModal();
+ // 7b. Voice Settings Modal (TTS)
+ initVoiceSettingsModal();
+
// 8. Hash Change Listener
window.addEventListener('hashchange', handleRouting);
diff --git a/app/static/js/modules/api.js b/app/static/js/modules/api.js
index 53e07be..e7d8e7d 100644
--- a/app/static/js/modules/api.js
+++ b/app/static/js/modules/api.js
@@ -185,8 +185,12 @@ export const api = {
// Listening Comprehension Trainer
listening: {
- async getExercise(topic = 'all', level = 'B1') {
- const resp = await fetch(`/api/listening/exercise?topic=${encodeURIComponent(topic)}&level=${encodeURIComponent(level)}`);
+ async getExercise(topic = 'all', level = 'B1', voice = null) {
+ let url = `/api/listening/exercise?topic=${encodeURIComponent(topic)}&level=${encodeURIComponent(level)}`;
+ if (voice) {
+ url += `&voice=${encodeURIComponent(voice)}`;
+ }
+ const resp = await fetch(url);
if (resp.ok) return await resp.json();
throw new Error(`Failed to load listening exercise: ${resp.status}`);
},
@@ -206,6 +210,14 @@ export const api = {
}
},
+ // Neural Speech Synthesis (TTS)
+ tts: {
+ async getStatus() {
+ const resp = await fetch('/api/tts/status');
+ return resp.ok ? await resp.json() : null;
+ }
+ },
+
// Bookshelf & Reader
books: {
async getAll() {
diff --git a/app/static/js/modules/srs.js b/app/static/js/modules/srs.js
index a76e25d..22bc01e 100644
--- a/app/static/js/modules/srs.js
+++ b/app/static/js/modules/srs.js
@@ -111,6 +111,7 @@ export function initSrs() {
const listeningLevelBadge = document.getElementById('listening-level-badge');
const listeningTopicBadge = document.getElementById('listening-topic-badge');
const listeningTopicSelect = document.getElementById('listening-topic-select');
+ const listeningVoiceSelect = document.getElementById('listening-voice-select');
const listeningSpeedSelect = document.getElementById('listening-speed-select');
const listeningNewScenarioBtn = document.getElementById('listening-new-scenario-btn');
const listeningTitle = document.getElementById('listening-title');
@@ -715,8 +716,9 @@ export function initSrs() {
if (listeningTitle) listeningTitle.textContent = 'ΠΠ°Π³ΡΡΠ·ΠΊΠ° Π°ΡΠ΄ΠΈΠΎ-ΡΠΈΡΡΠ°ΡΠΈΠΈ...';
const topic = listeningTopicSelect ? listeningTopicSelect.value : 'all';
+ const voice = listeningVoiceSelect ? listeningVoiceSelect.value : (localStorage.getItem('notes_tts_voice_en') || 'en_US-lessac');
try {
- const data = await api.listening.getExercise(topic, 'B1');
+ const data = await api.listening.getExercise(topic, 'B1', voice);
currentListeningExercise = data;
if (listeningTitle) listeningTitle.textContent = data.title || 'Π‘ΠΈΡΡΠ°ΡΠΈΡ Π΄Π»Ρ Π°ΡΠ΄ΠΈΡΠΎΠ²Π°Π½ΠΈΡ';
@@ -788,6 +790,14 @@ export function initSrs() {
});
}
+ if (listeningVoiceSelect) {
+ listeningVoiceSelect.value = localStorage.getItem('notes_tts_voice_en') || 'en_US-lessac';
+ listeningVoiceSelect.addEventListener('change', () => {
+ localStorage.setItem('notes_tts_voice_en', listeningVoiceSelect.value);
+ loadListeningExercise(true);
+ });
+ }
+
if (listeningForm) {
listeningForm.addEventListener('submit', async (e) => {
e.preventDefault();
diff --git a/app/static/js/modules/ui.js b/app/static/js/modules/ui.js
index b19730c..dd90da7 100644
--- a/app/static/js/modules/ui.js
+++ b/app/static/js/modules/ui.js
@@ -194,8 +194,14 @@ export function playAudio(url, speed = 1.0) {
}
}
-export function playTextSpeech(text, lang = 'en', speed = 1.0) {
+export function playTextSpeech(text, lang = 'en', speed = 1.0, voice = null) {
if (!text || !text.trim()) return null;
- const url = `/api/tts?text=${encodeURIComponent(text.trim())}&lang=${lang}&speed=${speed}`;
+ const isRu = lang && lang.toLowerCase().startsWith('ru');
+ const savedVoice = isRu ? localStorage.getItem('notes_tts_voice_ru') : localStorage.getItem('notes_tts_voice_en');
+ const activeVoice = voice || savedVoice;
+ let url = `/api/tts?text=${encodeURIComponent(text.trim())}&lang=${lang}&speed=${speed}`;
+ if (activeVoice) {
+ url += `&voice=${encodeURIComponent(activeVoice)}`;
+ }
return playAudio(url, speed);
}
diff --git a/app/static/sw.js b/app/static/sw.js
index 8b8259c..dd768f0 100644
--- a/app/static/sw.js
+++ b/app/static/sw.js
@@ -1,7 +1,7 @@
// Service Worker for Notes English PWA (Stage 2)
// Strategy: Cache-first for static assets, Network-first for API calls.
-const CACHE_NAME = 'notes-v6.5';
+const CACHE_NAME = 'notes-v6.6';
const OFFLINE_URL = '/';
const PRECACHE_URLS = [
diff --git a/app/tts.py b/app/tts.py
index 7e5d030..0562d10 100644
--- a/app/tts.py
+++ b/app/tts.py
@@ -4,7 +4,7 @@ import hashlib
import asyncio
import logging
from pathlib import Path
-from typing import Optional, Dict, Any
+from typing import Optional, Dict, Any, List
from app import config
@@ -18,45 +18,127 @@ 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": {
+# Curated high-fidelity Piper ONNX models for English and Russian
+VOICE_MODELS: Dict[str, Dict[str, Any]] = {
+ # --- English Voices ---
+ "en_US-lessac": {
+ "id": "en_US-lessac",
+ "lang": "en",
+ "gender": "female",
+ "title": "Paula (US β’ ΠΠ΅Π½ΡΠΊΠΈΠΉ, ΡΠΏΠΎΠΊΠΎΠΉΠ½ΡΠΉ)",
"model": MODELS_DIR / "en_US-lessac-medium.onnx",
"config": MODELS_DIR / "en_US-lessac-medium.onnx.json",
- "name": "en_US-lessac",
+ "base_length_scale": 1.0,
},
- "ru": {
+ "en_US-ryan": {
+ "id": "en_US-ryan",
+ "lang": "en",
+ "gender": "male",
+ "title": "Ryan (US β’ ΠΡΠΆΡΠΊΠΎΠΉ, Π΅ΡΡΠ΅ΡΡΠ²Π΅Π½Π½ΡΠΉ)",
+ "model": MODELS_DIR / "en_US-ryan-medium.onnx",
+ "config": MODELS_DIR / "en_US-ryan-medium.onnx.json",
+ "base_length_scale": 1.0,
+ },
+ "en_GB-alan": {
+ "id": "en_GB-alan",
+ "lang": "en",
+ "gender": "male",
+ "title": "Alan (UK β’ ΠΡΠΈΡΠ°Π½ΡΠΊΠΈΠΉ Π°ΠΊΡΠ΅Π½Ρ)",
+ "model": MODELS_DIR / "en_GB-alan-medium.onnx",
+ "config": MODELS_DIR / "en_GB-alan-medium.onnx.json",
+ "base_length_scale": 1.0,
+ },
+
+ # --- Russian Voices ---
+ "ru_RU-irina": {
+ "id": "ru_RU-irina",
+ "lang": "ru",
+ "gender": "female",
+ "title": "ΠΡΠΈΠ½Π° (ΠΠ΅Π½ΡΠΊΠΈΠΉ, ΡΡΡΠ΄ΠΈΠΉΠ½ΡΠΉ, ΠΏΠ»Π°Π²Π½ΡΠΉ)",
+ "model": MODELS_DIR / "ru_RU-irina-medium.onnx",
+ "config": MODELS_DIR / "ru_RU-irina-medium.onnx.json",
+ "base_length_scale": 1.0,
+ },
+ "ru_RU-denis": {
+ "id": "ru_RU-denis",
+ "lang": "ru",
+ "gender": "male",
+ "title": "ΠΠ΅Π½ΠΈΡ (ΠΡΠΆΡΠΊΠΎΠΉ, ΡΠΌΠ΅ΡΠ΅Π½Π½ΡΠΉ)",
+ "model": MODELS_DIR / "ru_RU-denis-medium.onnx",
+ "config": MODELS_DIR / "ru_RU-denis-medium.onnx.json",
+ "base_length_scale": 1.08,
+ },
+ "ru_RU-dmitri": {
+ "id": "ru_RU-dmitri",
+ "lang": "ru",
+ "gender": "male",
+ "title": "ΠΠΌΠΈΡΡΠΈΠΉ (ΠΡΠΆΡΠΊΠΎΠΉ, ΡΠ½Π΅ΡΠ³ΠΈΡΠ½ΡΠΉ)",
"model": MODELS_DIR / "ru_RU-dmitri-medium.onnx",
"config": MODELS_DIR / "ru_RU-dmitri-medium.onnx.json",
- "name": "ru_RU-dmitri",
+ "base_length_scale": 1.18, # Calibrated to eliminate rushing
},
}
+DEFAULT_VOICES = {
+ "en": "en_US-lessac",
+ "ru": "ru_RU-irina", # Irina provides natural pacing and clear punctuation pauses
+}
-def clean_tts_text(text: str) -> str:
- """Cleans input text from markdown, HTML tags, and abnormal whitespace."""
+
+def clean_tts_text(text: str, lang: str = "en") -> str:
+ """
+ Cleans input text from markdown, HTML tags, abnormal whitespace and
+ normalizes punctuation pauses so the engine doesn't swallow commas or dashes.
+ """
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)
+
+ # Normalize quotes
+ t = t.replace("Β«", '"').replace("Β»", '"').replace("β", '"').replace("β", '"')
+
+ # Normalize em-dashes and en-dashes for natural acoustic pauses:
+ # Em-dash (β) and en-dash (β) are not in Piper's phoneme map, so eSpeak discards them without pause.
+ # Replacing surrounded dashes with a comma-pause ensures an intonational breath pause.
+ t = re.sub(r"\s*[ββ]\s*", ", β ", t)
+ t = re.sub(r"\s*--\s*", ", β ", t)
+
+ # Fix punctuation without trailing space: "ΡΠ»ΠΎΠ²ΠΎ,ΡΠ»ΠΎΠ²ΠΎ" -> "ΡΠ»ΠΎΠ²ΠΎ, ΡΠ»ΠΎΠ²ΠΎ"
+ t = re.sub(r"([,;:!?])([^\s0-9])", r"\1 \2", 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()
+def resolve_voice(lang: str = "en", voice: Optional[str] = None) -> Dict[str, Any]:
+ """Resolves requested voice ID or falls back to default voice for language."""
+ norm_lang = "ru" if lang.lower().startswith("ru") else "en"
+ if voice and voice in VOICE_MODELS:
+ candidate = VOICE_MODELS[voice]
+ if candidate["lang"] == norm_lang:
+ return candidate
+ def_id = DEFAULT_VOICES.get(norm_lang, "en_US-lessac")
+ return VOICE_MODELS.get(def_id, list(VOICE_MODELS.values())[0])
+
+
+def get_audio_hash(text: str, lang: str = "en", speed: float = 1.0, voice: Optional[str] = None) -> str:
+ """Computes a deterministic MD5 hash for audio caching, accounting for voice, speed and text."""
+ clean = clean_tts_text(text, lang).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")
+ v_info = resolve_voice(norm_lang, voice)
+ v_id = v_info["id"]
+ key = f"{norm_lang}:{v_id}:{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]:
+def get_cached_audio_path(text: str, lang: str = "en", speed: float = 1.0, voice: Optional[str] = None) -> Optional[Path]:
"""Returns Path if already cached and valid, else None."""
- h = get_audio_hash(text, lang, speed)
+ h = get_audio_hash(text, lang, speed, voice)
file_path = AUDIO_CACHE_DIR / f"{h}.wav"
if file_path.is_file() and file_path.stat().st_size > 100:
return file_path
@@ -64,28 +146,52 @@ def get_cached_audio_path(text: str, lang: str = "en", speed: float = 1.0) -> Op
def is_piper_available() -> bool:
- """Checks if piper binary and at least en model exist and are executable."""
+ """Checks if piper binary and at least one 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()
+ return any(v["model"].is_file() for v in VOICE_MODELS.values())
+
+
+def get_available_voices() -> Dict[str, Any]:
+ """Returns metadata for all available models and active default voices."""
+ available = []
+ for k, v in VOICE_MODELS.items():
+ if v["model"].is_file():
+ available.append({
+ "id": v["id"],
+ "lang": v["lang"],
+ "gender": v["gender"],
+ "title": v["title"],
+ "is_default": (v["id"] == DEFAULT_VOICES.get(v["lang"])),
+ })
+ return {
+ "available": bool(available),
+ "engine": "piper-vits",
+ "default_voices": DEFAULT_VOICES,
+ "voices": available,
+ }
async def synthesize_speech(
text: str,
lang: str = "en",
speed: float = 1.0,
+ voice: Optional[str] = None,
) -> 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)
+ clean = clean_tts_text(text, lang)
if not clean:
return None
+ norm_lang = "ru" if lang.lower().startswith("ru") else "en"
+ voice_info = resolve_voice(norm_lang, voice)
+ v_id = voice_info["id"]
+
# Check cache first
- cached_path = get_cached_audio_path(clean, lang, speed)
+ cached_path = get_cached_audio_path(clean, norm_lang, speed, v_id)
if cached_path:
return cached_path
@@ -93,25 +199,22 @@ async def synthesize_speech(
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"]
+ base_length_scale = voice_info.get("base_length_scale", 1.0)
if not model_path.is_file() or not config_path.is_file():
- logger.warning(f"Voice model files missing for {norm_lang}: {model_path}")
+ logger.warning(f"Voice model files missing for {v_id}: {model_path}")
return None
- h = get_audio_hash(clean, norm_lang, speed)
+ h = get_audio_hash(clean, norm_lang, speed, v_id)
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))
+ # Apply voice-specific base length scale calibration
+ eff_length_scale = base_length_scale * (1.0 / clamped_speed)
+ length_scale_str = str(round(eff_length_scale, 2))
env = os.environ.copy()
ld_path = str(PIPER_DIR)
@@ -125,7 +228,8 @@ async def synthesize_speech(
"--model", str(model_path),
"--config", str(config_path),
"--espeak_data", str(PIPER_DIR / "espeak-ng-data"),
- "--length_scale", length_scale,
+ "--length_scale", length_scale_str,
+ "--sentence_silence", "0.35",
"--output_file", str(temp_wav),
]
@@ -141,7 +245,7 @@ async def synthesize_speech(
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}")
+ logger.info(f"Synthesized speech [{v_id}, speed={speed}]: {clean[:40]}... -> {out_wav.name}")
return out_wav
else:
err_text = stderr.decode("utf-8", errors="replace").strip()
@@ -156,19 +260,18 @@ async def synthesize_speech(
return None
-async def prewarm_card_audio(term: str, context: Optional[str] = None):
+async def prewarm_card_audio(term: str, context: Optional[str] = None, voice: 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)
+ await synthesize_speech(term.strip(), lang="en", speed=1.0, voice=voice)
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)
+ await synthesize_speech(clean_ctx, lang="en", speed=1.0, voice=voice)
except Exception as e:
logger.warning(f"Background prewarm error: {e}")
diff --git a/tests/test_api.py b/tests/test_api.py
index fda3591..d239e48 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -2242,7 +2242,11 @@ def test_piper_tts_endpoints_and_caching():
data = resp.json()
assert "available" in data
assert data["engine"] == "piper-vits"
- assert "en_US-lessac" in data["voices"]
+ voice_ids = [v["id"] for v in data["voices"]]
+ assert "en_US-lessac" in voice_ids
+ assert "ru_RU-irina" in voice_ids
+ assert "en_US-ryan" in voice_ids
+ assert "en_GB-alan" in voice_ids
# 2. Test TTS synthesis of English word
tts_resp = client.get("/api/tts?text=Astronomy&lang=en&speed=1.0", headers=auth_headers)
@@ -2250,6 +2254,12 @@ def test_piper_tts_endpoints_and_caching():
assert "audio/wav" in tts_resp.headers.get("content-type", "")
assert len(tts_resp.content) > 1000
+ # 2b. Test TTS synthesis of Russian phrase with dash and Irina voice
+ ru_resp = client.get("/api/tts?text=ΠΠ°ΠΆΠ½ΡΠΉ%20ΠΏΡΠΎΠ΅ΠΊΡ%20β%20ΠΎΠ½%20ΡΠ°Π±ΠΎΡΠ°Π΅Ρ&lang=ru&voice=ru_RU-irina", headers=auth_headers)
+ assert ru_resp.status_code == 200
+ assert "audio/wav" in ru_resp.headers.get("content-type", "")
+ assert len(ru_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