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

This commit is contained in:
Antigravity committed 2026-10-07 10:40:02 +03:00
1 parent 915d2784a2
commit a6fb4349e9
12 files changed
+514 -27

No files matched your search

+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")