feat(tts): integrate autonomous Piper neural TTS with zero-latency pre-warming for words and context
This commit is contained in:
+109
-5
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user