feat(tts): integrate autonomous Piper neural TTS with zero-latency pre-warming for words and context

This commit is contained in:
2026-10-07 10:40:02 +03:00
parent 915d2784a2
commit a6fb4349e9
12 changed files with 514 additions and 27 deletions
+109 -5
View File
@@ -1,4 +1,5 @@
import os
import re
import json
import urllib.parse
from pathlib import Path
@@ -6,7 +7,7 @@ from contextlib import asynccontextmanager
from typing import Optional, List, Dict, Any
from datetime import datetime, timezone, date
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.middleware.gzip import GZipMiddleware
from pydantic import BaseModel, Field
@@ -59,6 +60,13 @@ from app.tasks import (
cancel_task,
)
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()
if not clean_s:
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 "")
@@ -471,6 +483,63 @@ async def api_ai_status(user: dict = Depends(require_user)):
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
@@ -557,7 +626,14 @@ async def api_get_cards(
params.extend([limit, offset])
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}
@@ -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()
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
# Pre-warm word and context audio in background
asyncio.create_task(prewarm_card_audio(clean_term, card.context_sentence))
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(
"""
INSERT INTO vocabulary_cards (
@@ -614,7 +701,7 @@ async def api_create_card(card: CardCreateRequest, user: dict = Depends(require_
card.translation.strip(),
(card.context_sentence or "").strip(),
(card.phonetic or "").strip(),
(card.audio_url or "").strip(),
audio_url_val,
(card.part_of_speech or "").strip(),
(card.definition_en or "").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
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()
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")
+31
View File
@@ -905,6 +905,37 @@ body.drawer-open {
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 {
background-color: var(--bg-main);
border-left: 3px solid var(--primary);
+13 -4
View File
@@ -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">
<title>Notes — Персональный тренажер и словарь английского языка</title>
<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 -->
<link rel="manifest" href="/manifest.json">
@@ -319,7 +319,10 @@
<p id="fc-back-def" class="detail-text"></p>
</div>
<div class="fc-back-detail-box" id="fc-back-context-box">
<span class="detail-label">Контекст:</span>
<div class="fc-detail-header-row" style="display: flex; align-items: center; justify-content: space-between;">
<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>
</div>
<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>
<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>
</div>
<div id="popup-sentence-loading" class="popup-sentence-loading hidden">
<span class="spinner-sm"></span> Локальный ИИ переводит...
</div>
<div id="popup-sentence-translation-wrap" class="popup-sentence-translation-wrap hidden">
<div class="popup-sentence-ru" id="popup-sentence-ru"></div>
<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>
<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">
<span class="context-meaning-label">🎯 В контексте:</span>
<strong id="popup-context-meaning-val" class="context-meaning-val"></strong>
@@ -1697,7 +1706,7 @@
<!-- Audio Element for native streaming -->
<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>
</html>
+10 -2
View File
@@ -3,7 +3,7 @@
// =======================================================================
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 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() {
const term = termInput.value.trim();
if (!term) return;
@@ -188,7 +196,7 @@ export function initDictionary() {
resPhonetic.textContent = data.phonetic || '';
resPhonetic.style.display = data.phonetic ? 'inline-block' : 'none';
resPos.textContent = data.part_of_speech || 'Phrase';
audioPlayBtn.classList.add('hidden');
if (audioPlayBtn) audioPlayBtn.classList.remove('hidden');
if (data.definition_en) {
resDefinition.textContent = data.definition_en;
+39 -2
View File
@@ -3,7 +3,7 @@
// =======================================================================
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';
// Reader State & Appearance Settings
@@ -1454,7 +1454,11 @@ export async function openWordPopup(term, contextSentence, targetElement, paragr
if (popupTranslation) popupTranslation.textContent = trans || 'Перевод не найден';
if (popupLoading) popupLoading.classList.add('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 libCard = data.library_card || existingCard;
@@ -2096,6 +2100,9 @@ export function initReader() {
window.addEventListener('scroll', schedulePopupUpdate, { passive: true, capture: 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 popupUseContextMeaningBtn = document.getElementById('popup-use-context-meaning-btn');
const popupSentenceLoading = document.getElementById('popup-sentence-loading');
@@ -2105,6 +2112,36 @@ export function initReader() {
const popupContextMeaningVal = document.getElementById('popup-context-meaning-val');
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) {
popupTranslateSentenceBtn.addEventListener('click', async (e) => {
e.stopPropagation();
+47 -3
View File
@@ -52,6 +52,7 @@ export function initSrs() {
const fcBackDef = document.getElementById('fc-back-def');
const fcBackContextBox = document.getElementById('fc-back-context-box');
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 fcBackNotes = document.getElementById('fc-back-notes');
const fcFlipBtnWrap = document.getElementById('fc-flip-btn-wrap');
@@ -298,8 +299,41 @@ export function initSrs() {
fcBackNotes.textContent = card.notes || '';
fcBackNotesBox.style.display = card.notes ? 'block' : 'none';
fcFrontAudioBtn.style.display = 'none';
fcBackAudioBtn.style.display = 'none';
if (fcFrontAudioBtn) fcFrontAudioBtn.style.display = 'inline-flex';
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() {
@@ -363,7 +397,7 @@ export function initSrs() {
quizContextHint.classList.add('hidden');
}
quizAudioBtn.classList.add('hidden');
if (quizAudioBtn) quizAudioBtn.classList.remove('hidden');
const correctText = card.translation;
const all = getAllCards();
@@ -436,6 +470,16 @@ export function initSrs() {
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', () => {
srsCurrentIndex++;
renderSrsCurrentCard();
+23 -3
View File
@@ -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) {
if (!url) return;
let activeAudio = null;
export function playAudio(url, speed = 1.0) {
if (!url) return null;
try {
if (activeAudio) {
try {
activeAudio.pause();
activeAudio.currentTime = 0;
} catch (e) {}
}
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));
return audio;
} catch (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
View File
@@ -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.3';
const CACHE_NAME = 'notes-v6.4';
const OFFLINE_URL = '/';
const PRECACHE_URLS = [
+174
View File
@@ -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}")