feat: 集成Piper自建TTS并增加朗读回退
This commit is contained in:
@@ -7,6 +7,7 @@
|
|||||||
- 📝 西班牙语日记:新建、编辑、删除、按日期管理
|
- 📝 西班牙语日记:新建、编辑、删除、按日期管理
|
||||||
- 🌐 实时翻译:输入西语自动显示英语和中文对照
|
- 🌐 实时翻译:输入西语自动显示英语和中文对照
|
||||||
- 🔊 发音朗读:支持西语、英语、中文三种语言(Web Speech API)
|
- 🔊 发音朗读:支持西语、英语、中文三种语言(Web Speech API)
|
||||||
|
- 🔊 Piper 回退:手机无系统 TTS 时自动调用自建 Piper 服务并缓存音频
|
||||||
- 📖 单词本:自动提取输入中的关键词并给出解释,支持点击发音
|
- 📖 单词本:自动提取输入中的关键词并给出解释,支持点击发音
|
||||||
- 📕 专用单词本页面:支持搜索、分类筛选、添加、编辑、删除和三语朗读
|
- 📕 专用单词本页面:支持搜索、分类筛选、添加、编辑、删除和三语朗读
|
||||||
- 📚 语法知识库:按教材整理的 40 个语音和语法知识点
|
- 📚 语法知识库:按教材整理的 40 个语音和语法知识点
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from server.auth import admin_required, authenticate, create_user, login_require
|
|||||||
from server.db import BASE_DIR, get_db, init_db
|
from server.db import BASE_DIR, get_db, init_db
|
||||||
from server.entries_api import entries_bp
|
from server.entries_api import entries_bp
|
||||||
from server.settings_api import settings_bp
|
from server.settings_api import settings_bp
|
||||||
|
from server.tts_api import tts_bp
|
||||||
from server.vocab_api import vocab_bp
|
from server.vocab_api import vocab_bp
|
||||||
|
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ def create_app():
|
|||||||
app.register_blueprint(entries_bp)
|
app.register_blueprint(entries_bp)
|
||||||
app.register_blueprint(vocab_bp)
|
app.register_blueprint(vocab_bp)
|
||||||
app.register_blueprint(settings_bp)
|
app.register_blueprint(settings_bp)
|
||||||
|
app.register_blueprint(tts_bp)
|
||||||
|
|
||||||
@app.post("/api/register")
|
@app.post("/api/register")
|
||||||
def register():
|
def register():
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
from flask import Blueprint, jsonify, request, send_file
|
||||||
|
|
||||||
|
from server.auth import login_required
|
||||||
|
from server.tts_service import generate_speech, piper_available
|
||||||
|
|
||||||
|
tts_bp = Blueprint("tts", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@tts_bp.route("/api/tts", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
def tts_audio(user):
|
||||||
|
if request.method == "POST":
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
text = (data.get("text") or "").strip()
|
||||||
|
lang = (data.get("lang") or "es").strip().lower()
|
||||||
|
else:
|
||||||
|
text = (request.args.get("text") or "").strip()
|
||||||
|
lang = (request.args.get("lang") or "es").strip().lower()
|
||||||
|
|
||||||
|
if not text:
|
||||||
|
return jsonify({"error": "朗读内容不能为空"}), 400
|
||||||
|
if len(text) > 1000:
|
||||||
|
return jsonify({"error": "朗读内容不能超过 1000 个字符"}), 400
|
||||||
|
if lang not in ("es", "en", "zh"):
|
||||||
|
return jsonify({"error": "不支持的朗读语言"}), 400
|
||||||
|
if not piper_available():
|
||||||
|
return jsonify({"error": "Piper 未配置或未安装"}), 503
|
||||||
|
|
||||||
|
try:
|
||||||
|
audio_path = generate_speech(text, lang)
|
||||||
|
except Exception as exc:
|
||||||
|
return jsonify({"error": "Piper 生成失败: " + str(exc)}), 500
|
||||||
|
|
||||||
|
return send_file(
|
||||||
|
audio_path,
|
||||||
|
mimetype="audio/wav",
|
||||||
|
as_attachment=False,
|
||||||
|
download_name="piper.wav",
|
||||||
|
conditional=True,
|
||||||
|
)
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
SERVER_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
DEFAULT_CACHE_DIR = os.path.join(SERVER_DIR, "data", "tts_cache")
|
||||||
|
|
||||||
|
PIPER_BIN = os.environ.get("PIPER_BIN", "/opt/piper/venv/bin/piper")
|
||||||
|
PIPER_MODELS_DIR = os.environ.get("PIPER_MODELS_DIR", "/opt/piper/voices")
|
||||||
|
TTS_CACHE_DIR = os.environ.get("TTS_CACHE_DIR", DEFAULT_CACHE_DIR)
|
||||||
|
|
||||||
|
VOICES = {
|
||||||
|
"es": "es_ES-davefx-medium.onnx",
|
||||||
|
"en": "en_US-lessac-medium.onnx",
|
||||||
|
"zh": "zh_CN-huayan-medium.onnx",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def piper_available():
|
||||||
|
return bool(PIPER_BIN) and os.path.isfile(PIPER_BIN)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_speech(text, lang="es"):
|
||||||
|
voice = VOICES.get(lang, VOICES["en"])
|
||||||
|
digest = hashlib.sha256((lang + "\0" + text).encode("utf-8")).hexdigest()[:24]
|
||||||
|
cache_path = os.path.join(TTS_CACHE_DIR, f"{lang}_{digest}.wav")
|
||||||
|
if os.path.isfile(cache_path):
|
||||||
|
return cache_path
|
||||||
|
|
||||||
|
model_path = os.path.join(PIPER_MODELS_DIR, voice)
|
||||||
|
if not os.path.isfile(model_path):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Piper 模型不存在: {model_path},请先下载语音模型并配置 PIPER_MODELS_DIR"
|
||||||
|
)
|
||||||
|
|
||||||
|
os.makedirs(TTS_CACHE_DIR, exist_ok=True)
|
||||||
|
tmp_path = cache_path + ".tmp"
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
[
|
||||||
|
PIPER_BIN,
|
||||||
|
"--model",
|
||||||
|
model_path,
|
||||||
|
"--output_file",
|
||||||
|
tmp_path,
|
||||||
|
],
|
||||||
|
input=text.encode("utf-8"),
|
||||||
|
capture_output=True,
|
||||||
|
timeout=90,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired as exc:
|
||||||
|
raise RuntimeError("Piper 生成超时") from exc
|
||||||
|
|
||||||
|
if proc.returncode != 0 or not os.path.isfile(tmp_path):
|
||||||
|
stderr = proc.stderr.decode("utf-8", errors="replace")
|
||||||
|
raise RuntimeError(stderr or "Piper 生成失败")
|
||||||
|
|
||||||
|
os.replace(tmp_path, cache_path)
|
||||||
|
return cache_path
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
var speechVoices = [];
|
var speechVoices = [];
|
||||||
var speechVoicesLoaded = false;
|
var speechVoicesLoaded = false;
|
||||||
var speechTimer = null;
|
var speechTimer = null;
|
||||||
|
var piperFallbackTimer = null;
|
||||||
|
|
||||||
function refreshSpeechVoices() {
|
function refreshSpeechVoices() {
|
||||||
if (!window.speechSynthesis) return;
|
if (!window.speechSynthesis) return;
|
||||||
@@ -48,10 +49,39 @@ function doSpeak(text, lang) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function speakText(text, lang) {
|
function speakText(text, lang) {
|
||||||
if (!text || !window.speechSynthesis) return;
|
if (!text) return;
|
||||||
|
if (piperFallbackTimer) clearTimeout(piperFallbackTimer);
|
||||||
|
if (!window.speechSynthesis || !window.SpeechSynthesisUtterance) {
|
||||||
|
playPiperAudio(text, lang);
|
||||||
|
return;
|
||||||
|
}
|
||||||
refreshSpeechVoices();
|
refreshSpeechVoices();
|
||||||
doSpeak(text, lang);
|
doSpeak(text, lang);
|
||||||
window.speechSynthesis.getVoices();
|
window.speechSynthesis.getVoices();
|
||||||
|
if (!speechVoicesLoaded) {
|
||||||
|
if (piperFallbackTimer) clearTimeout(piperFallbackTimer);
|
||||||
|
piperFallbackTimer = setTimeout(function() {
|
||||||
|
refreshSpeechVoices();
|
||||||
|
if (!speechVoicesLoaded) playPiperAudio(text, lang);
|
||||||
|
}, 900);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function playPiperAudio(text, lang) {
|
||||||
|
if (!text) return;
|
||||||
|
fetch('/api/tts', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text: text, lang: lang || 'es' })
|
||||||
|
}).then(function(res) {
|
||||||
|
if (!res.ok) throw new Error('tts failed');
|
||||||
|
return res.blob();
|
||||||
|
}).then(function(blob) {
|
||||||
|
var url = URL.createObjectURL(blob);
|
||||||
|
var audio = new Audio(url);
|
||||||
|
audio.onended = function() { URL.revokeObjectURL(url); };
|
||||||
|
audio.play();
|
||||||
|
}).catch(function() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
function initSpeech() {
|
function initSpeech() {
|
||||||
|
|||||||
Reference in New Issue
Block a user