diff --git a/README.md b/README.md index 22b0825..71aad6e 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ - 📝 西班牙语日记:新建、编辑、删除、按日期管理 - 🌐 实时翻译:输入西语自动显示英语和中文对照 - 🔊 发音朗读:支持西语、英语、中文三种语言(Web Speech API) +- 🔊 Piper 回退:手机无系统 TTS 时自动调用自建 Piper 服务并缓存音频 - 📖 单词本:自动提取输入中的关键词并给出解释,支持点击发音 - 📕 专用单词本页面:支持搜索、分类筛选、添加、编辑、删除和三语朗读 - 📚 语法知识库:按教材整理的 40 个语音和语法知识点 diff --git a/server/app.py b/server/app.py index 97151d2..bb83726 100644 --- a/server/app.py +++ b/server/app.py @@ -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.entries_api import entries_bp from server.settings_api import settings_bp +from server.tts_api import tts_bp from server.vocab_api import vocab_bp @@ -28,6 +29,7 @@ def create_app(): app.register_blueprint(entries_bp) app.register_blueprint(vocab_bp) app.register_blueprint(settings_bp) + app.register_blueprint(tts_bp) @app.post("/api/register") def register(): diff --git a/server/tts_api.py b/server/tts_api.py new file mode 100644 index 0000000..eb8ec9f --- /dev/null +++ b/server/tts_api.py @@ -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, + ) diff --git a/server/tts_service.py b/server/tts_service.py new file mode 100644 index 0000000..4d0e452 --- /dev/null +++ b/server/tts_service.py @@ -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 diff --git a/speech.js b/speech.js index d6f11cb..6f432df 100644 --- a/speech.js +++ b/speech.js @@ -1,6 +1,7 @@ var speechVoices = []; var speechVoicesLoaded = false; var speechTimer = null; +var piperFallbackTimer = null; function refreshSpeechVoices() { if (!window.speechSynthesis) return; @@ -48,10 +49,39 @@ function doSpeak(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(); doSpeak(text, lang); 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() {