feat(tts): multi-voice switcher for EN & RU, Irina default voice, punctuation pause normalization and speed calibration

This commit is contained in:
2026-10-07 12:07:01 +03:00
parent 7893b353aa
commit 31e105b2c2
11 changed files with 486 additions and 61 deletions
+9 -8
View File
@@ -167,6 +167,7 @@ async def get_listening_exercise(
topic: Optional[str] = None, topic: Optional[str] = None,
level: Optional[str] = None, level: Optional[str] = None,
user_cards: Optional[List[Dict[str, Any]]] = None, user_cards: Optional[List[Dict[str, Any]]] = None,
voice: Optional[str] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Selects or generates a listening exercise, ensuring audio is pre-synthesized. Selects or generates a listening exercise, ensuring audio is pre-synthesized.
@@ -197,13 +198,13 @@ async def get_listening_exercise(
item_id = f"card-{card.get('id', 0)}" item_id = f"card-{card.get('id', 0)}"
# Pre-synthesize audio # Pre-synthesize audio
story_hash = get_audio_hash(ctx, "en", 1.0) story_hash = get_audio_hash(ctx, "en", 1.0, voice=voice)
question_text = f"Which key word or concept is highlighted in this context?" question_text = f"Which key word or concept is highlighted in this context?"
question_hash = get_audio_hash(question_text, "en", 1.0) question_hash = get_audio_hash(question_text, "en", 1.0, voice=voice)
# Fire synthesis # Fire synthesis
asyncio.create_task(synthesize_speech(ctx, lang="en", speed=1.0)) asyncio.create_task(synthesize_speech(ctx, lang="en", speed=1.0, voice=voice))
asyncio.create_task(synthesize_speech(question_text, lang="en", speed=1.0)) asyncio.create_task(synthesize_speech(question_text, lang="en", speed=1.0, voice=voice))
return { return {
"id": item_id, "id": item_id,
@@ -225,11 +226,11 @@ async def get_listening_exercise(
scenario = random.choice(candidates) scenario = random.choice(candidates)
# Ensure audio is pre-synthesized # Ensure audio is pre-synthesized
story_hash = get_audio_hash(scenario["story"], "en", 1.0) story_hash = get_audio_hash(scenario["story"], "en", 1.0, voice=voice)
question_hash = get_audio_hash(scenario["question"], "en", 1.0) question_hash = get_audio_hash(scenario["question"], "en", 1.0, voice=voice)
asyncio.create_task(synthesize_speech(scenario["story"], lang="en", speed=1.0)) asyncio.create_task(synthesize_speech(scenario["story"], lang="en", speed=1.0, voice=voice))
asyncio.create_task(synthesize_speech(scenario["question"], lang="en", speed=1.0)) asyncio.create_task(synthesize_speech(scenario["question"], lang="en", speed=1.0, voice=voice))
return { return {
"id": scenario["id"], "id": scenario["id"],
+9 -11
View File
@@ -66,6 +66,7 @@ from app.tts import (
prewarm_card_audio, prewarm_card_audio,
AUDIO_CACHE_DIR, AUDIO_CACHE_DIR,
is_piper_available, is_piper_available,
get_available_voices,
) )
from app.listening import ( from app.listening import (
get_listening_exercise, get_listening_exercise,
@@ -523,16 +524,17 @@ async def api_tts(
text: str = Query(..., min_length=1, max_length=1000), text: str = Query(..., min_length=1, max_length=1000),
lang: str = Query("en"), lang: str = Query("en"),
speed: float = Query(1.0, ge=0.5, le=2.0), speed: float = Query(1.0, ge=0.5, le=2.0),
voice: Optional[str] = Query(None),
user: dict = Depends(require_user), user: dict = Depends(require_user),
): ):
""" """
Synthesizes speech on-demand using local Piper TTS (en_US-lessac / ru_RU-dmitri). Synthesizes speech on-demand using local Piper TTS.
Instantly returns cached audio if previously synthesized. Instantly returns cached audio if previously synthesized for this voice, speed, and text.
""" """
clean_text = text.strip() clean_text = text.strip()
if not clean_text: if not clean_text:
raise HTTPException(status_code=400, detail="Text cannot be empty") raise HTTPException(status_code=400, detail="Text cannot be empty")
audio_path = await synthesize_speech(clean_text, lang=lang, speed=speed) audio_path = await synthesize_speech(clean_text, lang=lang, speed=speed, voice=voice)
if not audio_path or not audio_path.is_file(): if not audio_path or not audio_path.is_file():
raise HTTPException(status_code=500, detail="TTS synthesis failed or unavailable") raise HTTPException(status_code=500, detail="TTS synthesis failed or unavailable")
return FileResponse( return FileResponse(
@@ -544,13 +546,8 @@ async def api_tts(
@app.get("/api/tts/status") @app.get("/api/tts/status")
async def api_tts_status(user: dict = Depends(require_user)): async def api_tts_status(user: dict = Depends(require_user)):
"""Returns status of local Piper TTS and available voice models.""" """Returns status of local Piper TTS and all available voice models."""
return { return get_available_voices()
"available": is_piper_available(),
"engine": "piper-vits",
"voices": ["en_US-lessac", "ru_RU-dmitri"],
"cache_dir": str(AUDIO_CACHE_DIR),
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -561,6 +558,7 @@ async def api_tts_status(user: dict = Depends(require_user)):
async def api_listening_exercise( async def api_listening_exercise(
topic: Optional[str] = Query("all"), topic: Optional[str] = Query("all"),
level: Optional[str] = Query("B1"), level: Optional[str] = Query("B1"),
voice: Optional[str] = Query(None),
user: dict = Depends(require_user), user: dict = Depends(require_user),
): ):
""" """
@@ -581,7 +579,7 @@ async def api_listening_exercise(
).fetchall() ).fetchall()
user_cards = [dict(r) for r in rows] user_cards = [dict(r) for r in rows]
ex = await get_listening_exercise(topic=topic, level=level, user_cards=user_cards) ex = await get_listening_exercise(topic=topic, level=level, user_cards=user_cards, voice=voice)
# Strip keywords and ideal_answer from client response to prevent inspection # Strip keywords and ideal_answer from client response to prevent inspection
safe_ex = {k: v for k, v in ex.items() if k not in ("keywords", "ideal_answer")} safe_ex = {k: v for k, v in ex.items() if k not in ("keywords", "ideal_answer")}
return safe_ex return safe_ex
+115
View File
@@ -2431,6 +2431,121 @@ body.drawer-open {
border-top: 1px solid var(--border-color); border-top: 1px solid var(--border-color);
} }
/* Voice Settings Modal */
.voice-modal-window {
max-width: 680px;
width: 95%;
}
.voice-modal-body {
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 16px;
max-height: 80vh;
overflow-y: auto;
}
.modal-desc-sub {
font-size: 13px;
color: var(--text-muted);
margin: 0 0 10px 0;
}
.voice-group-title {
font-size: 14px;
font-weight: 700;
color: var(--text-main);
display: block;
margin-bottom: 10px;
}
.voice-options-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
@media (max-width: 680px) {
.voice-options-grid {
grid-template-columns: 1fr;
}
}
.voice-card-label {
cursor: pointer;
display: block;
position: relative;
user-select: none;
}
.voice-card-label input[type="radio"] {
position: absolute;
opacity: 0;
pointer-events: none;
}
.voice-card {
border: 1px solid var(--border-color);
background: var(--bg-card);
border-radius: var(--radius-md);
padding: 14px 14px;
display: flex;
flex-direction: column;
gap: 8px;
height: 100%;
transition: var(--transition);
}
.voice-card:hover {
border-color: var(--primary-dim);
}
.voice-card-label input[type="radio"]:checked + .voice-card {
border-color: var(--primary);
background: var(--bg-main);
box-shadow: 0 0 0 2px var(--primary-dim);
}
.voice-card-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 6px;
}
.voice-card-desc {
font-size: 12px;
line-height: 1.4;
color: var(--text-muted);
flex: 1;
}
.badge-recommended {
background: rgba(16, 185, 129, 0.15);
color: #10b981;
font-size: 10px;
font-weight: 700;
padding: 2px 6px;
border-radius: 4px;
white-space: nowrap;
}
.badge-uk {
background: rgba(59, 130, 246, 0.15);
color: #3b82f6;
font-size: 10px;
font-weight: 700;
padding: 2px 6px;
border-radius: 4px;
white-space: nowrap;
}
.voice-test-btn {
align-self: flex-start;
margin-top: 4px;
}
/* Summary Card */ /* Summary Card */
.srs-summary-card { .srs-summary-card {
background-color: var(--bg-card); background-color: var(--bg-card);
+102
View File
@@ -512,6 +512,11 @@
<option value="business">💼 Работа & Бизнес</option> <option value="business">💼 Работа & Бизнес</option>
<option value="cards">📚 Мои слова из словаря</option> <option value="cards">📚 Мои слова из словаря</option>
</select> </select>
<select id="listening-voice-select" class="select-styled select-xs" title="Голос английского диктора">
<option value="en_US-lessac">👩 Paula (US)</option>
<option value="en_US-ryan">👨 Ryan (US)</option>
<option value="en_GB-alan">👨 Alan (UK)</option>
</select>
<select id="listening-speed-select" class="select-styled select-xs" title="Скорость речи"> <select id="listening-speed-select" class="select-styled select-xs" title="Скорость речи">
<option value="1.0">⚡ 1.0x</option> <option value="1.0">⚡ 1.0x</option>
<option value="0.75">🐢 0.75x</option> <option value="0.75">🐢 0.75x</option>
@@ -1184,6 +1189,12 @@
<span class="drawer-item-label">Переключить тему оформления</span> <span class="drawer-item-label">Переключить тему оформления</span>
</button> </button>
</li> </li>
<li>
<button class="drawer-nav-item" id="drawer-nav-voice-settings">
<span class="drawer-item-icon">🎙️</span>
<span class="drawer-item-label">Голоса озвучки (TTS)</span>
</button>
</li>
<li> <li>
<button class="drawer-nav-item text-danger" id="drawer-nav-logout"> <button class="drawer-nav-item text-danger" id="drawer-nav-logout">
<span class="drawer-item-icon">🚪</span> <span class="drawer-item-icon">🚪</span>
@@ -1392,6 +1403,97 @@
</div> </div>
</div> </div>
<!-- Voice Settings Modal -->
<div id="voice-settings-modal" class="modal-backdrop hidden">
<div class="modal-window voice-modal-window">
<div class="modal-header">
<h3>🎙️ Настройка дикторов (TTS)</h3>
<button id="voice-settings-close-btn" class="icon-btn" title="Закрыть">✕</button>
</div>
<div class="modal-body voice-modal-body">
<p class="modal-desc-sub">Выберите голос для озвучки английских и русских текстов. Нейросеть работает полностью автономно.</p>
<div class="voice-config-group">
<label class="voice-group-title">🇷🇺 Русский диктор (перевод и контекст):</label>
<div class="voice-options-grid">
<label class="voice-card-label">
<input type="radio" name="tts_voice_ru" value="ru_RU-irina" checked>
<div class="voice-card">
<div class="voice-card-head">
<strong>👩 Ирина</strong>
<span class="badge-recommended">Рекомендуется</span>
</div>
<small class="voice-card-desc">Плавный студийный голос, естественные паузы на тире и запятых, спокойный темп.</small>
<button type="button" class="btn btn-xs btn-outline voice-test-btn" data-lang="ru" data-voice="ru_RU-irina" data-text="Это важный проект — он требует внимания, концентрации и времени.">▶ Тест звука</button>
</div>
</label>
<label class="voice-card-label">
<input type="radio" name="tts_voice_ru" value="ru_RU-denis">
<div class="voice-card">
<div class="voice-card-head">
<strong>👨 Денис</strong>
</div>
<small class="voice-card-desc">Спокойный мужской тембр, ровная дикция.</small>
<button type="button" class="btn btn-xs btn-outline voice-test-btn" data-lang="ru" data-voice="ru_RU-denis" data-text="Это важный проект — он требует внимания, концентрации и времени.">▶ Тест звука</button>
</div>
</label>
<label class="voice-card-label">
<input type="radio" name="tts_voice_ru" value="ru_RU-dmitri">
<div class="voice-card">
<div class="voice-card-head">
<strong>👨 Дмитрий</strong>
</div>
<small class="voice-card-desc">Энергичный мужской голос с оптимизированным размеренным темпом.</small>
<button type="button" class="btn btn-xs btn-outline voice-test-btn" data-lang="ru" data-voice="ru_RU-dmitri" data-text="Это важный проект — он требует внимания, концентрации и времени.">▶ Тест звука</button>
</div>
</label>
</div>
</div>
<div class="voice-config-group" style="margin-top: 20px;">
<label class="voice-group-title">🇬🇧 / 🇺🇸 Английский диктор (слова, аудирование, книги):</label>
<div class="voice-options-grid">
<label class="voice-card-label">
<input type="radio" name="tts_voice_en" value="en_US-lessac" checked>
<div class="voice-card">
<div class="voice-card-head">
<strong>👩 Paula (US)</strong>
<span class="badge-recommended">По умолчанию</span>
</div>
<small class="voice-card-desc">Американский английский. Четкий академический женский голос.</small>
<button type="button" class="btn btn-xs btn-outline voice-test-btn" data-lang="en" data-voice="en_US-lessac" data-text="The quick brown fox jumps over the lazy dog.">▶ Test Voice</button>
</div>
</label>
<label class="voice-card-label">
<input type="radio" name="tts_voice_en" value="en_US-ryan">
<div class="voice-card">
<div class="voice-card-head">
<strong>👨 Ryan (US)</strong>
</div>
<small class="voice-card-desc">Американский английский. Естественный, выразительный мужской голос.</small>
<button type="button" class="btn btn-xs btn-outline voice-test-btn" data-lang="en" data-voice="en_US-ryan" data-text="The quick brown fox jumps over the lazy dog.">▶ Test Voice</button>
</div>
</label>
<label class="voice-card-label">
<input type="radio" name="tts_voice_en" value="en_GB-alan">
<div class="voice-card">
<div class="voice-card-head">
<strong>👨 Alan (UK)</strong>
<span class="badge-uk">British</span>
</div>
<small class="voice-card-desc">Британский английский (Received Pronunciation), классический мужской тембр.</small>
<button type="button" class="btn btn-xs btn-outline voice-test-btn" data-lang="en" data-voice="en_GB-alan" data-text="Good afternoon! The train to Oxford departs from platform four.">▶ Test Voice</button>
</div>
</label>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" id="voice-settings-save-btn" class="btn btn-primary">Сохранить выбор</button>
</div>
</div>
</div>
<!-- Wikipedia Settings Modal --> <!-- Wikipedia Settings Modal -->
<div id="wiki-settings-modal" class="modal-backdrop hidden"> <div id="wiki-settings-modal" class="modal-backdrop hidden">
<div class="modal-window wiki-modal-window"> <div class="modal-window wiki-modal-window">
+69 -1
View File
@@ -4,7 +4,7 @@
// ======================================================================= // =======================================================================
import { api } from './modules/api.js'; 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 { initDictionary, loadInitialData } from './modules/dictionary.js';
import { initSrs, startSrsTrainingSession, openStatsDashboard } from './modules/srs.js'; import { initSrs, startSrsTrainingSession, openStatsDashboard } from './modules/srs.js';
import { initReader, loadBookshelf, openBook } from './modules/reader.js'; import { initReader, loadBookshelf, openBook } from './modules/reader.js';
@@ -546,6 +546,14 @@ export function initNavigationDrawer() {
themeToggle.click(); 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) { if (drawerNavLogout && logoutBtn) {
drawerNavLogout.addEventListener('click', () => { drawerNavLogout.addEventListener('click', () => {
closeDrawer(); 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 // Password Change Modal
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -695,6 +760,9 @@ document.addEventListener('DOMContentLoaded', () => {
// 7. Change Password Modal // 7. Change Password Modal
initChangePasswordModal(); initChangePasswordModal();
// 7b. Voice Settings Modal (TTS)
initVoiceSettingsModal();
// 8. Hash Change Listener // 8. Hash Change Listener
window.addEventListener('hashchange', handleRouting); window.addEventListener('hashchange', handleRouting);
+14 -2
View File
@@ -185,8 +185,12 @@ export const api = {
// Listening Comprehension Trainer // Listening Comprehension Trainer
listening: { listening: {
async getExercise(topic = 'all', level = 'B1') { async getExercise(topic = 'all', level = 'B1', voice = null) {
const resp = await fetch(`/api/listening/exercise?topic=${encodeURIComponent(topic)}&level=${encodeURIComponent(level)}`); 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(); if (resp.ok) return await resp.json();
throw new Error(`Failed to load listening exercise: ${resp.status}`); 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 // Bookshelf & Reader
books: { books: {
async getAll() { async getAll() {
+11 -1
View File
@@ -111,6 +111,7 @@ export function initSrs() {
const listeningLevelBadge = document.getElementById('listening-level-badge'); const listeningLevelBadge = document.getElementById('listening-level-badge');
const listeningTopicBadge = document.getElementById('listening-topic-badge'); const listeningTopicBadge = document.getElementById('listening-topic-badge');
const listeningTopicSelect = document.getElementById('listening-topic-select'); const listeningTopicSelect = document.getElementById('listening-topic-select');
const listeningVoiceSelect = document.getElementById('listening-voice-select');
const listeningSpeedSelect = document.getElementById('listening-speed-select'); const listeningSpeedSelect = document.getElementById('listening-speed-select');
const listeningNewScenarioBtn = document.getElementById('listening-new-scenario-btn'); const listeningNewScenarioBtn = document.getElementById('listening-new-scenario-btn');
const listeningTitle = document.getElementById('listening-title'); const listeningTitle = document.getElementById('listening-title');
@@ -715,8 +716,9 @@ export function initSrs() {
if (listeningTitle) listeningTitle.textContent = 'Загрузка аудио-ситуации...'; if (listeningTitle) listeningTitle.textContent = 'Загрузка аудио-ситуации...';
const topic = listeningTopicSelect ? listeningTopicSelect.value : 'all'; const topic = listeningTopicSelect ? listeningTopicSelect.value : 'all';
const voice = listeningVoiceSelect ? listeningVoiceSelect.value : (localStorage.getItem('notes_tts_voice_en') || 'en_US-lessac');
try { try {
const data = await api.listening.getExercise(topic, 'B1'); const data = await api.listening.getExercise(topic, 'B1', voice);
currentListeningExercise = data; currentListeningExercise = data;
if (listeningTitle) listeningTitle.textContent = data.title || 'Ситуация для аудирования'; 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) { if (listeningForm) {
listeningForm.addEventListener('submit', async (e) => { listeningForm.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
+8 -2
View File
@@ -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; 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); return playAudio(url, speed);
} }
+1 -1
View File
@@ -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.5'; const CACHE_NAME = 'notes-v6.6';
const OFFLINE_URL = '/'; const OFFLINE_URL = '/';
const PRECACHE_URLS = [ const PRECACHE_URLS = [
+137 -34
View File
@@ -4,7 +4,7 @@ import hashlib
import asyncio import asyncio
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Optional, Dict, Any from typing import Optional, Dict, Any, List
from app import config from app import config
@@ -18,45 +18,127 @@ AUDIO_CACHE_DIR = config.BASE_DIR / "data" / "audio_cache"
# Ensure audio cache directory exists # Ensure audio cache directory exists
AUDIO_CACHE_DIR.mkdir(parents=True, exist_ok=True) AUDIO_CACHE_DIR.mkdir(parents=True, exist_ok=True)
VOICE_MODELS = { # Curated high-fidelity Piper ONNX models for English and Russian
"en": { 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", "model": MODELS_DIR / "en_US-lessac-medium.onnx",
"config": MODELS_DIR / "en_US-lessac-medium.onnx.json", "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", "model": MODELS_DIR / "ru_RU-dmitri-medium.onnx",
"config": MODELS_DIR / "ru_RU-dmitri-medium.onnx.json", "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: if not text:
return "" return ""
# Remove HTML tags if any # Remove HTML tags if any
t = re.sub(r"<[^>]+>", " ", text) t = re.sub(r"<[^>]+>", " ", text)
# Remove markdown link formatting [text](url) -> text # Remove markdown link formatting [text](url) -> text
t = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", t) 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 # Collapse multiple whitespaces
t = " ".join(t.strip().split()) t = " ".join(t.strip().split())
return t return t
def get_audio_hash(text: str, lang: str = "en", speed: float = 1.0) -> str: def resolve_voice(lang: str = "en", voice: Optional[str] = None) -> Dict[str, Any]:
"""Computes a deterministic MD5 hash for audio caching.""" """Resolves requested voice ID or falls back to default voice for language."""
clean = clean_tts_text(text).lower() 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_speed = round(float(speed), 2)
norm_lang = "ru" if lang.lower().startswith("ru") else "en" 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() 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.""" """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" file_path = AUDIO_CACHE_DIR / f"{h}.wav"
if file_path.is_file() and file_path.stat().st_size > 100: if file_path.is_file() and file_path.stat().st_size > 100:
return file_path 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: 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): if not PIPER_BIN.is_file() or not os.access(PIPER_BIN, os.X_OK):
return False return False
en_model = VOICE_MODELS["en"]["model"] return any(v["model"].is_file() for v in VOICE_MODELS.values())
return en_model.is_file()
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( async def synthesize_speech(
text: str, text: str,
lang: str = "en", lang: str = "en",
speed: float = 1.0, speed: float = 1.0,
voice: Optional[str] = None,
) -> Optional[Path]: ) -> Optional[Path]:
""" """
Synthesizes speech using local Piper TTS and returns Path to the generated WAV file. 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. 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: if not clean:
return None 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 # 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: if cached_path:
return cached_path return cached_path
@@ -93,25 +199,22 @@ async def synthesize_speech(
logger.warning("Piper TTS binary or models not available") logger.warning("Piper TTS binary or models not available")
return None 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"] model_path = voice_info["model"]
config_path = voice_info["config"] 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(): 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 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" out_wav = AUDIO_CACHE_DIR / f"{h}.wav"
temp_wav = AUDIO_CACHE_DIR / f"{h}.tmp.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))) 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() env = os.environ.copy()
ld_path = str(PIPER_DIR) ld_path = str(PIPER_DIR)
@@ -125,7 +228,8 @@ async def synthesize_speech(
"--model", str(model_path), "--model", str(model_path),
"--config", str(config_path), "--config", str(config_path),
"--espeak_data", str(PIPER_DIR / "espeak-ng-data"), "--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), "--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: if proc.returncode == 0 and temp_wav.is_file() and temp_wav.stat().st_size > 100:
temp_wav.replace(out_wav) 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 return out_wav
else: else:
err_text = stderr.decode("utf-8", errors="replace").strip() err_text = stderr.decode("utf-8", errors="replace").strip()
@@ -156,19 +260,18 @@ async def synthesize_speech(
return None 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. Background pre-synthesis of word and context sentence.
Invoked when a user saves a card to ensure zero-latency audio playback in SRS. Invoked when a user saves a card to ensure zero-latency audio playback in SRS.
""" """
try: try:
if term and term.strip(): 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(): if context and context.strip():
clean_ctx = context.strip().replace('"', '').strip() clean_ctx = context.strip().replace('"', '').strip()
# If context is very long, cap to first sentence or 300 chars
if len(clean_ctx) > 300: if len(clean_ctx) > 300:
clean_ctx = 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: except Exception as e:
logger.warning(f"Background prewarm error: {e}") logger.warning(f"Background prewarm error: {e}")
+11 -1
View File
@@ -2242,7 +2242,11 @@ def test_piper_tts_endpoints_and_caching():
data = resp.json() data = resp.json()
assert "available" in data assert "available" in data
assert data["engine"] == "piper-vits" 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 # 2. Test TTS synthesis of English word
tts_resp = client.get("/api/tts?text=Astronomy&lang=en&speed=1.0", headers=auth_headers) 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 "audio/wav" in tts_resp.headers.get("content-type", "")
assert len(tts_resp.content) > 1000 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 # 3. Test repeat request returns cached audio
cached_resp = client.get("/api/tts?text=Astronomy&lang=en&speed=1.0", headers=auth_headers) cached_resp = client.get("/api/tts?text=Astronomy&lang=en&speed=1.0", headers=auth_headers)
assert cached_resp.status_code == 200 assert cached_resp.status_code == 200