修复微软edge-tts报403的bug,优化UI界面
This commit is contained in:
1 parent
e39f9025ce
commit
b081bba33c
6 files changed
+1159
-1087
No files matched your search
@@ -11,7 +11,7 @@ Web广播系统 - 吆喝用的喇叭(i人专用)
|
|||||||
- **循环播放**:支持设置间隔时间的循环播放模式
|
- **循环播放**:支持设置间隔时间的循环播放模式
|
||||||
- **语音文件管理**:播放历史、收藏、重命名、删除
|
- **语音文件管理**:播放历史、收藏、重命名、删除
|
||||||
- **主题切换**:支持深色/浅色主题
|
- **主题切换**:支持深色/浅色主题
|
||||||
- **实时日志**:显示操作日志和状态信息
|
- **状态条与日志**:一条状态随时说明喇叭在做什么(待机 / 正在合成 / 正在播报 / 上次没成功),需要时展开看完整日志
|
||||||
|
|
||||||
## 支持的声音
|
## 支持的声音
|
||||||
|
|
||||||
@@ -40,8 +40,20 @@ pip install flask edge-tts
|
|||||||
|
|
||||||
# 安装音频播放工具
|
# 安装音频播放工具
|
||||||
sudo apt-get install mpg123
|
sudo apt-get install mpg123
|
||||||
|
|
||||||
|
# 修复 edge-tts 的 403(微软 2026-10 更换了端点,不修则合成不出语音)
|
||||||
|
python3 patch_edge_tts_403.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 界面
|
||||||
|
|
||||||
|
控制界面按「极简一页」来组织:一屏只做一件事——写内容、点广播,音频文件与日志收在下方可展开的行里;
|
||||||
|
宽屏(≥1000px)时这两块自动变成右侧常显的一栏。
|
||||||
|
|
||||||
|
配色遵循一条规则:**颜色只表示喇叭是否在响**。待机时整个界面是无彩的(只有灰绿中性色),
|
||||||
|
只有正在播报时才会出现唯一的朱红——页面顶端一条横线、状态点、以及变成「停止广播」的主按钮。
|
||||||
|
深浅两套主题,右上角切换,首次打开跟随系统。
|
||||||
|
|
||||||
## 使用方法
|
## 使用方法
|
||||||
|
|
||||||
### 启动服务
|
### 启动服务
|
||||||
@@ -77,6 +89,7 @@ python app.py
|
|||||||
PA_horn_speaker/
|
PA_horn_speaker/
|
||||||
├── app.py # Flask 应用主文件
|
├── app.py # Flask 应用主文件
|
||||||
├── player.sh # 循环播放脚本
|
├── player.sh # 循环播放脚本
|
||||||
|
├── patch_edge_tts_403.py # 修复 edge-tts 合成 403 的补丁脚本
|
||||||
├── templates/
|
├── templates/
|
||||||
│ └── index.html # 前端页面模板
|
│ └── index.html # 前端页面模板
|
||||||
├── static/
|
├── static/
|
||||||
@@ -99,12 +112,39 @@ PA_horn_speaker/
|
|||||||
| `/rename-file` | POST | 重命名文件 |
|
| `/rename-file` | POST | 重命名文件 |
|
||||||
| `/toggle-favorite` | POST | 收藏/取消收藏 |
|
| `/toggle-favorite` | POST | 收藏/取消收藏 |
|
||||||
| `/delete-file` | POST | 删除文件 |
|
| `/delete-file` | POST | 删除文件 |
|
||||||
|
| `/status` | GET | 查询喇叭是否正在播报、播的哪一条、循环间隔 |
|
||||||
|
|
||||||
|
## 已知问题:合成语音报 403(2026-10)
|
||||||
|
|
||||||
|
**现象**:输入文字后点「开始广播」没有声音,日志中 `edge_tts` 报
|
||||||
|
`WSServerHandshakeError: 403, message='Invalid response status'`。
|
||||||
|
|
||||||
|
**原因**:微软在 2026 年 10 月把 Edge Read Aloud 的合成端点从
|
||||||
|
`speech.platform.bing.com` 迁到了 `api.msedgeservices.com`。旧端点在
|
||||||
|
2026-10-10 起于多数地区开始返回 403,2026-10-12 起全球(含美国)全线 403。
|
||||||
|
edge-tts 7.2.8 尚未跟进,因此这不是本机、网络或时钟的问题——可以这样确认:
|
||||||
|
`--list-voices` 仍然正常返回、系统时钟已 NTP 同步、换一台机器同样 403。
|
||||||
|
|
||||||
|
**修复**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 patch_edge_tts_403.py # 打补丁
|
||||||
|
python3 patch_edge_tts_403.py --verify # 实际合成一段音频确认
|
||||||
|
```
|
||||||
|
|
||||||
|
补丁会把 `edge_tts/constants.py` 里的 `WSS_URL` 指向新端点,运行前会自动备份原文件。
|
||||||
|
注意:**重新安装或升级 edge-tts 会覆盖 `constants.py`,补丁随之丢失,重跑一次脚本即可。**
|
||||||
|
其它参数:`--check` 只看当前状态,`--revert` 还原成官方原始端点。
|
||||||
|
|
||||||
|
上游进展见 [rany2/edge-tts#490](https://github.com/rany2/edge-tts/issues/490),
|
||||||
|
对应修复 PR 为 [#491](https://github.com/rany2/edge-tts/pull/491)。
|
||||||
|
|
||||||
## 注意事项
|
## 注意事项
|
||||||
|
|
||||||
1. 需要联网使用(edge-tts 需要访问 Microsoft 服务)
|
1. 需要联网使用(edge-tts 需要访问 Microsoft 服务)
|
||||||
2. 音频输出使用 ALSA 设备 `plughw:audiocodec,0`,可根据实际设备调整
|
2. 音频输出使用 ALSA 设备 `plughw:audiocodec,0`,可根据实际设备调整
|
||||||
3. 缓存目录最多保存10个文件,收藏的文件不会被自动清理
|
3. 缓存目录最多保存10个文件,收藏的文件不会被自动清理
|
||||||
|
4. 合成失败会自动重试 3 次(新端点对同一 IP 有限流),仍失败则返回错误提示,不会再把空音频文件当成正常广播;缓存里已损坏(0 字节)的文件也不会被播放
|
||||||
|
|
||||||
## APK 文件
|
## APK 文件
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import subprocess
|
|||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
@@ -32,6 +33,52 @@ voices = {
|
|||||||
|
|
||||||
current_process = None
|
current_process = None
|
||||||
|
|
||||||
|
# 当前播放状态,供 /status 告诉界面「喇叭是不是正在响」。
|
||||||
|
playback = {'mode': None, 'filename': None, 'interval': None, 'started_at': None}
|
||||||
|
|
||||||
|
|
||||||
|
def _playback_alive():
|
||||||
|
"""喇叭是否真的在响。以进程为准,避免状态变量和实际情况脱节。"""
|
||||||
|
if current_process is not None and current_process.poll() is None:
|
||||||
|
return True
|
||||||
|
probe = subprocess.run(['pgrep', '-x', 'mpg123'], capture_output=True, text=True)
|
||||||
|
return probe.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def _stop_playback():
|
||||||
|
"""停掉当前播放并清空状态。"""
|
||||||
|
global current_process
|
||||||
|
|
||||||
|
if current_process:
|
||||||
|
try:
|
||||||
|
current_process.kill()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
current_process = None
|
||||||
|
|
||||||
|
subprocess.run(['pkill', '-9', 'mpg123'])
|
||||||
|
playback.update(mode=None, filename=None, interval=None, started_at=None)
|
||||||
|
|
||||||
|
|
||||||
|
def _start_playback(filename, loop, interval):
|
||||||
|
"""播放 cache 里的某个文件;loop 为真时交给 player.sh 循环。"""
|
||||||
|
global current_process
|
||||||
|
|
||||||
|
_stop_playback()
|
||||||
|
|
||||||
|
filepath = os.path.join(CACHE_DIR, filename)
|
||||||
|
if loop:
|
||||||
|
current_process = subprocess.Popen(['bash', 'player.sh', filepath, str(interval)])
|
||||||
|
mode = 'loop'
|
||||||
|
else:
|
||||||
|
current_process = subprocess.Popen([
|
||||||
|
'mpg123', '-o', 'alsa', '-a', 'plughw:audiocodec,0', filepath
|
||||||
|
])
|
||||||
|
mode = 'once'
|
||||||
|
|
||||||
|
playback.update(mode=mode, filename=filename, interval=interval,
|
||||||
|
started_at=time.time())
|
||||||
|
|
||||||
|
|
||||||
def get_metadata():
|
def get_metadata():
|
||||||
if not os.path.exists(METADATA_FILE):
|
if not os.path.exists(METADATA_FILE):
|
||||||
@@ -93,7 +140,6 @@ def index():
|
|||||||
|
|
||||||
@app.route('/speak', methods=['POST'])
|
@app.route('/speak', methods=['POST'])
|
||||||
def speak():
|
def speak():
|
||||||
global current_process
|
|
||||||
|
|
||||||
text = request.form.get('text')
|
text = request.form.get('text')
|
||||||
voice = request.form.get('voice')
|
voice = request.form.get('voice')
|
||||||
@@ -105,12 +151,26 @@ def speak():
|
|||||||
|
|
||||||
clean_cache()
|
clean_cache()
|
||||||
|
|
||||||
|
metadata = get_metadata()
|
||||||
|
|
||||||
|
for filename, info in metadata.items():
|
||||||
|
if info.get('text') == text and info.get('voice') == voice:
|
||||||
|
filepath = os.path.join(CACHE_DIR, filename)
|
||||||
|
if os.path.exists(filepath) and os.path.getsize(filepath) > 0:
|
||||||
|
_start_playback(filename, loop, interval)
|
||||||
|
|
||||||
|
return jsonify({'status': 'ok', 'filename': filename, 'message': '使用已存在的文件'})
|
||||||
|
|
||||||
filename = f"{uuid.uuid4()}.mp3"
|
filename = f"{uuid.uuid4()}.mp3"
|
||||||
filepath = os.path.join(CACHE_DIR, filename)
|
filepath = os.path.join(CACHE_DIR, filename)
|
||||||
|
|
||||||
voice_name = voices.get(voice, 'zh-CN-YunxiNeural')
|
voice_name = voices.get(voice, 'zh-CN-YunxiNeural')
|
||||||
|
|
||||||
subprocess.run([
|
# 合成音频。新端点会对同一 IP 限流(429),故失败后重试几次。
|
||||||
|
ok = False
|
||||||
|
err_tail = ''
|
||||||
|
for attempt in range(3):
|
||||||
|
proc = subprocess.run([
|
||||||
'python3',
|
'python3',
|
||||||
'-m',
|
'-m',
|
||||||
'edge_tts',
|
'edge_tts',
|
||||||
@@ -120,10 +180,26 @@ def speak():
|
|||||||
text,
|
text,
|
||||||
'--write-media',
|
'--write-media',
|
||||||
filepath
|
filepath
|
||||||
])
|
], capture_output=True, text=True)
|
||||||
|
lines = (proc.stderr or '').strip().splitlines()
|
||||||
|
err_tail = lines[-1] if lines else ''
|
||||||
|
if proc.returncode == 0 and os.path.exists(filepath) and os.path.getsize(filepath) > 0:
|
||||||
|
ok = True
|
||||||
|
break
|
||||||
|
time.sleep(2 * (attempt + 1))
|
||||||
|
|
||||||
metadata = get_metadata()
|
if not ok:
|
||||||
display_name = text[:30] + '...' if len(text) > 30 else text
|
# 合成失败:清掉可能的空文件、不写元数据,避免把坏文件当正常广播循环播放
|
||||||
|
if os.path.exists(filepath):
|
||||||
|
os.remove(filepath)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': '语音合成失败,微软接口未返回音频,请稍后重试。' + ((' 详情: ' + err_tail) if err_tail else '')
|
||||||
|
})
|
||||||
|
|
||||||
|
# 名字取前 30 个字;换行等连续空白折叠成一个空格,便于在列表和状态条里显示
|
||||||
|
flat = ' '.join(text.split())
|
||||||
|
display_name = flat[:30] + '...' if len(flat) > 30 else flat
|
||||||
metadata[filename] = {
|
metadata[filename] = {
|
||||||
'display_name': display_name,
|
'display_name': display_name,
|
||||||
'text': text,
|
'text': text,
|
||||||
@@ -132,40 +208,15 @@ def speak():
|
|||||||
}
|
}
|
||||||
save_metadata(metadata)
|
save_metadata(metadata)
|
||||||
|
|
||||||
if current_process:
|
_start_playback(filename, loop, interval)
|
||||||
current_process.kill()
|
|
||||||
|
|
||||||
subprocess.run(['pkill', '-9', 'mpg123'])
|
|
||||||
|
|
||||||
if loop:
|
|
||||||
current_process = subprocess.Popen([
|
|
||||||
'bash',
|
|
||||||
'player.sh',
|
|
||||||
filepath,
|
|
||||||
str(interval)
|
|
||||||
])
|
|
||||||
else:
|
|
||||||
current_process = subprocess.Popen([
|
|
||||||
'mpg123',
|
|
||||||
'-o',
|
|
||||||
'alsa',
|
|
||||||
'-a',
|
|
||||||
'plughw:audiocodec,0',
|
|
||||||
filepath
|
|
||||||
])
|
|
||||||
|
|
||||||
return jsonify({'status': 'ok', 'filename': filename})
|
return jsonify({'status': 'ok', 'filename': filename})
|
||||||
|
|
||||||
|
|
||||||
@app.route('/stop')
|
@app.route('/stop')
|
||||||
def stop():
|
def stop():
|
||||||
global current_process
|
|
||||||
|
|
||||||
if current_process:
|
_stop_playback()
|
||||||
current_process.kill()
|
|
||||||
current_process = None
|
|
||||||
|
|
||||||
subprocess.run(['pkill', '-9', 'mpg123'])
|
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
@@ -195,7 +246,6 @@ def list_files():
|
|||||||
|
|
||||||
@app.route('/play-file', methods=['POST'])
|
@app.route('/play-file', methods=['POST'])
|
||||||
def play_file():
|
def play_file():
|
||||||
global current_process
|
|
||||||
|
|
||||||
filename = request.form.get('filename')
|
filename = request.form.get('filename')
|
||||||
loop = request.form.get('loop') == 'true'
|
loop = request.form.get('loop') == 'true'
|
||||||
@@ -206,30 +256,10 @@ def play_file():
|
|||||||
|
|
||||||
filepath = os.path.join(CACHE_DIR, filename)
|
filepath = os.path.join(CACHE_DIR, filename)
|
||||||
|
|
||||||
if not os.path.exists(filepath):
|
if not os.path.exists(filepath) or os.path.getsize(filepath) == 0:
|
||||||
return jsonify({'status': 'error', 'message': '文件不存在'})
|
return jsonify({'status': 'error', 'message': '文件不存在或已损坏'})
|
||||||
|
|
||||||
if current_process:
|
_start_playback(filename, loop, interval)
|
||||||
current_process.kill()
|
|
||||||
|
|
||||||
subprocess.run(['pkill', '-9', 'mpg123'])
|
|
||||||
|
|
||||||
if loop:
|
|
||||||
current_process = subprocess.Popen([
|
|
||||||
'bash',
|
|
||||||
'player.sh',
|
|
||||||
filepath,
|
|
||||||
str(interval)
|
|
||||||
])
|
|
||||||
else:
|
|
||||||
current_process = subprocess.Popen([
|
|
||||||
'mpg123',
|
|
||||||
'-o',
|
|
||||||
'alsa',
|
|
||||||
'-a',
|
|
||||||
'plughw:audiocodec,0',
|
|
||||||
filepath
|
|
||||||
])
|
|
||||||
|
|
||||||
metadata = get_metadata()
|
metadata = get_metadata()
|
||||||
display_name = metadata.get(filename, {}).get('display_name', filename)
|
display_name = metadata.get(filename, {}).get('display_name', filename)
|
||||||
@@ -280,7 +310,6 @@ def toggle_favorite():
|
|||||||
|
|
||||||
@app.route('/delete-file', methods=['POST'])
|
@app.route('/delete-file', methods=['POST'])
|
||||||
def delete_file():
|
def delete_file():
|
||||||
global current_process
|
|
||||||
|
|
||||||
filename = request.form.get('filename')
|
filename = request.form.get('filename')
|
||||||
|
|
||||||
@@ -292,14 +321,7 @@ def delete_file():
|
|||||||
if not os.path.exists(filepath):
|
if not os.path.exists(filepath):
|
||||||
return jsonify({'status': 'error', 'message': '文件不存在'})
|
return jsonify({'status': 'error', 'message': '文件不存在'})
|
||||||
|
|
||||||
if current_process:
|
_stop_playback()
|
||||||
try:
|
|
||||||
current_process.kill()
|
|
||||||
current_process = None
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
subprocess.run(['pkill', '-9', 'mpg123'])
|
|
||||||
|
|
||||||
os.remove(filepath)
|
os.remove(filepath)
|
||||||
|
|
||||||
@@ -311,6 +333,21 @@ def delete_file():
|
|||||||
return jsonify({'status': 'ok', 'message': '删除成功'})
|
return jsonify({'status': 'ok', 'message': '删除成功'})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/status')
|
||||||
|
def playback_status():
|
||||||
|
"""界面用:喇叭现在是不是在响、在播哪一条、循环间隔多少秒。"""
|
||||||
|
alive = _playback_alive()
|
||||||
|
filename = playback['filename'] if alive else None
|
||||||
|
return jsonify({
|
||||||
|
'playing': alive,
|
||||||
|
'mode': playback['mode'] if alive else None,
|
||||||
|
'filename': filename,
|
||||||
|
'display_name': get_metadata().get(filename, {}).get('display_name') if filename else None,
|
||||||
|
'interval': playback['interval'] if alive else None,
|
||||||
|
'started_at': playback['started_at'] if alive else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
if not os.path.exists(CACHE_DIR):
|
if not os.path.exists(CACHE_DIR):
|
||||||
os.makedirs(CACHE_DIR)
|
os.makedirs(CACHE_DIR)
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""修复 edge-tts 的 403 问题(微软 2026-10 更换 Edge Read Aloud 端点)。
|
||||||
|
|
||||||
|
【现象】
|
||||||
|
合成语音时报:
|
||||||
|
aiohttp.client_exceptions.WSServerHandshakeError: 403, message='Invalid response status'
|
||||||
|
同时伴随这些特征(可用来确认不是自己的环境问题):
|
||||||
|
- `edge_tts --list-voices`(普通 HTTPS)仍然正常返回
|
||||||
|
- 系统时钟已 NTP 同步,偏差在几秒内
|
||||||
|
- edge-tts 已经是最新版(PyPI 上的 7.2.8)
|
||||||
|
- 换网络 / 换机器同样 403
|
||||||
|
|
||||||
|
【原因】
|
||||||
|
微软把 Edge Read Aloud 的「合成」端点从旧地址
|
||||||
|
speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1
|
||||||
|
迁到了新地址
|
||||||
|
api.msedgeservices.com/tts/cognitiveservices/websocket/v1
|
||||||
|
旧端点在 2026-10-10 起于多数地区开始返回 403,2026-10-12 起全球(含美国)
|
||||||
|
全线 403。而 edge-tts 7.2.8(2025-12 发布)尚未跟进。
|
||||||
|
上游讨论:https://github.com/rany2/edge-tts/issues/490
|
||||||
|
上游修复:https://github.com/rany2/edge-tts/pull/491 (未合并)
|
||||||
|
|
||||||
|
【做法】
|
||||||
|
把 edge_tts/constants.py 中的 WSS_URL 指向新端点。新端点同样接受旧 token,
|
||||||
|
参数名改为 Ocp-Apim-Subscription-Key;Sec-MS-GEC / Sec-MS-GEC-Version
|
||||||
|
继续沿用原来的 v1 形式即可,无需 v2 token。
|
||||||
|
|
||||||
|
【用法】
|
||||||
|
python3 patch_edge_tts_403.py # 打补丁
|
||||||
|
python3 patch_edge_tts_403.py --check # 只看当前状态,不改动
|
||||||
|
python3 patch_edge_tts_403.py --verify # 打补丁后实际合成一段音频来验证
|
||||||
|
python3 patch_edge_tts_403.py --revert # 还原成 edge-tts 的原始端点
|
||||||
|
|
||||||
|
【注意】
|
||||||
|
重新安装或升级 edge-tts 会覆盖 constants.py,补丁随之丢失,重跑本脚本即可。
|
||||||
|
新端点对同一 IP 有限流(429),脚本验证时会自动重试几次。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
|
||||||
|
# 补丁块的起始标记。用它来界定「补丁插入的内容」,
|
||||||
|
# 这样 --revert 能把注释和赋值语句一起清干净,逐字节还原成 edge-tts 原文件。
|
||||||
|
MARKER = "# edge-tts-403-fix (PA_horn_speaker)"
|
||||||
|
|
||||||
|
ORIGINAL_WSS_URL = 'WSS_URL = f"wss://{BASE_URL}/edge/v1?TrustedClientToken={TRUSTED_CLIENT_TOKEN}"\n'
|
||||||
|
|
||||||
|
PATCHED_WSS_URL = "\n".join([
|
||||||
|
MARKER,
|
||||||
|
"# 2026-10 起微软把 Edge Read Aloud 合成从旧端点 speech.platform.bing.com",
|
||||||
|
"# 迁到了 api.msedgeservices.com(旧端点在多数地区返回 403)。",
|
||||||
|
"# 新端点同样接受旧 token,参数名为 Ocp-Apim-Subscription-Key;",
|
||||||
|
"# Sec-MS-GEC / Sec-MS-GEC-Version 仍沿用旧的 v1 形式即可。",
|
||||||
|
"# 语音列表接口仍由旧端点提供,故 VOICE_LIST 保持不变。",
|
||||||
|
"WSS_URL = (",
|
||||||
|
' "wss://api.msedgeservices.com/tts/cognitiveservices/websocket/v1"',
|
||||||
|
' f"?Ocp-Apim-Subscription-Key={TRUSTED_CLIENT_TOKEN}"',
|
||||||
|
")",
|
||||||
|
"",
|
||||||
|
])
|
||||||
|
|
||||||
|
# 匹配「可选的补丁块」+「WSS_URL 赋值语句」(赋值可能是单行或跨行括号形式),
|
||||||
|
# 直到下一行的 VOICE_LIST 定义为止。
|
||||||
|
WSS_BLOCK = re.compile(
|
||||||
|
r"(?:^" + re.escape(MARKER) + r"\n(?:^#[^\n]*\n)*)?"
|
||||||
|
r"^WSS_URL = .*?\n(?=^VOICE_LIST = )",
|
||||||
|
re.S | re.M,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def find_constants_path():
|
||||||
|
"""定位已安装的 edge_tts/constants.py。"""
|
||||||
|
spec = importlib.util.find_spec("edge_tts")
|
||||||
|
if spec is None or not spec.origin:
|
||||||
|
raise SystemExit("找不到 edge_tts,请先安装:pip install edge-tts")
|
||||||
|
return os.path.join(os.path.dirname(spec.origin), "constants.py")
|
||||||
|
|
||||||
|
|
||||||
|
def read(path):
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
|
||||||
|
def is_patched(text):
|
||||||
|
return "api.msedgeservices.com" in text
|
||||||
|
|
||||||
|
|
||||||
|
def do_check(path):
|
||||||
|
text = read(path)
|
||||||
|
if not WSS_BLOCK.search(text):
|
||||||
|
raise SystemExit("未能在 %s 中定位 WSS_URL,edge-tts 结构可能已变化" % path)
|
||||||
|
print("edge-tts constants.py:%s" % path)
|
||||||
|
print("状态:%s" % ("已打补丁(指向新端点)" if is_patched(text) else "未打补丁(仍是旧端点,会 403)"))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def do_patch(path, revert=False):
|
||||||
|
text = read(path)
|
||||||
|
if not WSS_BLOCK.search(text):
|
||||||
|
raise SystemExit("未能在 %s 中定位 WSS_URL,edge-tts 结构可能已变化" % path)
|
||||||
|
|
||||||
|
patched = is_patched(text)
|
||||||
|
if revert:
|
||||||
|
if not patched:
|
||||||
|
print("当前已是 edge-tts 的原始端点,无需还原。")
|
||||||
|
return 0
|
||||||
|
replacement = ORIGINAL_WSS_URL
|
||||||
|
else:
|
||||||
|
if patched:
|
||||||
|
print("当前已指向新端点,无需重复打补丁。")
|
||||||
|
return 0
|
||||||
|
replacement = PATCHED_WSS_URL
|
||||||
|
|
||||||
|
backup = path + ".bak"
|
||||||
|
if not os.path.exists(backup):
|
||||||
|
shutil.copy2(path, backup)
|
||||||
|
print("已备份原文件到 %s" % backup)
|
||||||
|
|
||||||
|
new_text = WSS_BLOCK.sub(lambda _: replacement, text, count=1)
|
||||||
|
with open(path, "w", encoding="utf-8", newline="") as f:
|
||||||
|
f.write(new_text)
|
||||||
|
|
||||||
|
print("已%s:%s" % ("还原为原始端点" if revert else "指向新端点", path))
|
||||||
|
if not revert:
|
||||||
|
print("下一步可用 `python3 %s --verify` 实际合成一段音频确认。" % os.path.basename(__file__))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def do_verify(attempts=3):
|
||||||
|
"""实际合成一段音频,确认真能拿到数据(而不只是改了个变量)。"""
|
||||||
|
out = os.path.join(tempfile.gettempdir(), "edge_tts_verify.mp3")
|
||||||
|
for i in range(1, attempts + 1):
|
||||||
|
if os.path.exists(out):
|
||||||
|
os.remove(out)
|
||||||
|
proc = subprocess.run(
|
||||||
|
[sys.executable, "-m", "edge_tts",
|
||||||
|
"--voice", "zh-CN-XiaoxiaoNeural",
|
||||||
|
"--text", "测试广播,今天水果特价。",
|
||||||
|
"--write-media", out],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if proc.returncode == 0 and os.path.exists(out) and os.path.getsize(out) > 0:
|
||||||
|
print("验证通过:成功合成 %d 字节音频 -> %s" % (os.path.getsize(out), out))
|
||||||
|
return 0
|
||||||
|
tail = (proc.stderr or "").strip().splitlines()
|
||||||
|
print("第 %d/%d 次失败:%s" % (i, attempts, tail[-1] if tail else "无输出"))
|
||||||
|
if i < attempts:
|
||||||
|
time.sleep(2 * i)
|
||||||
|
print("验证失败。若错误仍是 403,说明端点又变了,请查看 "
|
||||||
|
"https://github.com/rany2/edge-tts/issues 上的最新讨论。")
|
||||||
|
print("若错误是 429,说明只是被限流,稍后重试即可。")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(
|
||||||
|
description="修复 edge-tts 的 403(微软 2026-10 更换 Edge Read Aloud 端点)")
|
||||||
|
ap.add_argument("--check", action="store_true", help="只检查当前状态,不改动文件")
|
||||||
|
ap.add_argument("--revert", action="store_true", help="还原为 edge-tts 的原始端点")
|
||||||
|
ap.add_argument("--verify", action="store_true", help="实际合成一段音频来验证")
|
||||||
|
ap.add_argument("--constants", metavar="PATH",
|
||||||
|
help="手动指定 constants.py 路径(默认自动定位已安装的 edge_tts)")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if args.check:
|
||||||
|
return do_check(args.constants or find_constants_path())
|
||||||
|
|
||||||
|
if args.verify:
|
||||||
|
return do_verify()
|
||||||
|
|
||||||
|
path = args.constants or find_constants_path()
|
||||||
|
return do_patch(path, revert=args.revert)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -5,6 +5,6 @@ INTERVAL=${2:-5}
|
|||||||
|
|
||||||
while true
|
while true
|
||||||
do
|
do
|
||||||
mpg123 -o alsa -a plughw:audiocodec,0 "$FILE"
|
mpg123 -f 131072 -o alsa -a plughw:audiocodec,0 "$FILE"
|
||||||
sleep "$INTERVAL"
|
sleep "$INTERVAL"
|
||||||
done
|
done
|
||||||
+431
-653
File diff suppressed because it is too large.
Load diff
+400
-367
@@ -1,452 +1,485 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<title>广播系统</title>
|
<meta name="theme-color" content="#F0F1EE">
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<title>喇叭</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css?v=2">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<h1>📢 Web广播系统</h1>
|
|
||||||
<button class="theme-toggle" onclick="toggleTheme()" title="切换主题">
|
|
||||||
<span id="theme-icon">🌙</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="main-content">
|
<div class="shell">
|
||||||
<div class="form-group">
|
|
||||||
<textarea
|
|
||||||
id="text"
|
|
||||||
placeholder="输入广播内容..."
|
|
||||||
maxlength="500"
|
|
||||||
></textarea>
|
|
||||||
<span class="char-count">0/500</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
<header class="top">
|
||||||
<label for="voice">选择声音</label>
|
<h1 class="wordmark">喇叭</h1>
|
||||||
|
<div class="top-actions">
|
||||||
|
<span class="live-dot" aria-hidden="true"></span>
|
||||||
|
<button class="ghost-btn" id="theme-btn" type="button">夜间</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="composer">
|
||||||
|
<label class="sr-only" for="text">广播内容</label>
|
||||||
|
<div class="slab">
|
||||||
|
<textarea id="text" rows="4" maxlength="500"
|
||||||
|
placeholder="输入今天要吆喝的内容…"></textarea>
|
||||||
|
<div class="slab-foot">
|
||||||
|
<span class="meta" id="count">0 / 500</span>
|
||||||
|
<label class="voice-pick">
|
||||||
|
<span class="sr-only">选择声音</span>
|
||||||
<select id="voice">
|
<select id="voice">
|
||||||
{% for k,v in voices.items() %}
|
{% for k,v in voices.items() %}<option>{{ k }}</option>{% endfor %}
|
||||||
<option>{{ k }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="checkbox-label">
|
|
||||||
<input type="checkbox" id="loop" onchange="toggleInterval()">
|
|
||||||
<span>循环播放</span>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group interval-group" id="interval-group" style="display: none;">
|
<div class="loop-row">
|
||||||
<label for="interval">循环间隔时间</label>
|
<label class="switch">
|
||||||
<div class="interval-input">
|
<input type="checkbox" id="loop">
|
||||||
<input
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
type="range"
|
<span class="switch-text">循环播报</span>
|
||||||
id="interval"
|
</label>
|
||||||
min="1"
|
<div class="interval" id="interval-wrap" hidden>
|
||||||
max="60"
|
<label class="sr-only" for="interval">循环间隔秒数</label>
|
||||||
value="5"
|
<input type="range" id="interval" min="1" max="60" value="5">
|
||||||
>
|
<output class="meta" id="interval-out" for="interval">每 5 秒</output>
|
||||||
<span id="interval-value">5</span>
|
|
||||||
<span class="unit">秒</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="button-group">
|
<button class="broadcast" id="go" type="button">开始广播</button>
|
||||||
<button class="btn btn-primary" onclick="speak()">
|
</main>
|
||||||
<span class="btn-icon">▶️</span>
|
|
||||||
开始广播
|
<section class="drawer" id="history">
|
||||||
</button>
|
<button class="drawer-head" type="button" aria-expanded="false" aria-controls="history-body">
|
||||||
<button class="btn btn-danger" onclick="stopPlay()">
|
<span class="drawer-title">之前播过的</span>
|
||||||
<span class="btn-icon">⏹️</span>
|
<span class="drawer-meta" id="history-count">0 条</span>
|
||||||
停止播放
|
<span class="caret" aria-hidden="true"></span>
|
||||||
</button>
|
</button>
|
||||||
|
<div class="drawer-body" id="history-body">
|
||||||
|
<div class="rows" id="files-list"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<div class="files-container">
|
<section class="drawer" id="status">
|
||||||
<div class="files-header">
|
<button class="drawer-head" type="button" aria-expanded="false" aria-controls="status-body">
|
||||||
<button class="btn btn-secondary btn-small refresh-btn" onclick="loadFiles()" title="刷新列表">
|
<span class="drawer-title" id="status-text">待机</span>
|
||||||
🔄刷新列表
|
<span class="drawer-meta" id="status-meta"></span>
|
||||||
|
<span class="caret" aria-hidden="true"></span>
|
||||||
</button>
|
</button>
|
||||||
<h3>语音文件列表</h3>
|
<div class="drawer-body" id="status-body">
|
||||||
</div>
|
<div class="logs" id="logs"></div>
|
||||||
<div id="files-list" class="files-list">
|
<button class="ghost-btn small" id="clear-logs" type="button">清空日志</button>
|
||||||
<div class="empty-state">
|
|
||||||
<span class="empty-icon">📭</span>
|
|
||||||
<p>暂无语音文件</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="logs-container">
|
</div>
|
||||||
<div class="logs-header">
|
|
||||||
<button class="btn btn-secondary btn-small clear-btn" onclick="clearLogs()" title="清空日志">
|
|
||||||
🗑️清空日志
|
|
||||||
</button>
|
|
||||||
<h3>运行日志</h3>
|
|
||||||
</div>
|
|
||||||
<div id="logs" class="logs-content">
|
|
||||||
<p class="log-info">系统已就绪,等待操作...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const themeKey = 'broadcast-theme';
|
(function () {
|
||||||
const darkIcon = '🌙';
|
'use strict';
|
||||||
const lightIcon = '☀️';
|
|
||||||
|
var $ = function (id) { return document.getElementById(id); };
|
||||||
|
|
||||||
|
var THEME_KEY = 'broadcast-theme';
|
||||||
|
var themeMeta = document.querySelector('meta[name="theme-color"]');
|
||||||
|
var COLORS = { light: '#F0F1EE', dark: '#121614' };
|
||||||
|
|
||||||
|
var elText = $('text'), elVoice = $('voice'), elLoop = $('loop'),
|
||||||
|
elInterval = $('interval'), elIntervalWrap = $('interval-wrap'),
|
||||||
|
elIntervalOut = $('interval-out'), elCount = $('count'),
|
||||||
|
elGo = $('go'), elFiles = $('files-list'), elHistoryCount = $('history-count'),
|
||||||
|
elStatusText = $('status-text'), elStatusMeta = $('status-meta'),
|
||||||
|
elLogs = $('logs');
|
||||||
|
|
||||||
|
/* ---------- 主题 ---------- */
|
||||||
|
|
||||||
|
function applyTheme(name) {
|
||||||
|
var light = name === 'light';
|
||||||
|
document.documentElement.classList.toggle('theme-light', light);
|
||||||
|
$('theme-btn').textContent = light ? '夜间' : '日间';
|
||||||
|
if (themeMeta) themeMeta.setAttribute('content', light ? COLORS.light : COLORS.dark);
|
||||||
|
try { localStorage.setItem(THEME_KEY, name); } catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
function initTheme() {
|
function initTheme() {
|
||||||
const savedTheme = localStorage.getItem(themeKey);
|
var saved = null;
|
||||||
if (savedTheme === 'light' || (!savedTheme && window.matchMedia('(prefers-color-scheme: light)').matches)) {
|
try { saved = localStorage.getItem(THEME_KEY); } catch (e) {}
|
||||||
setLightTheme();
|
if (saved !== 'light' && saved !== 'dark') {
|
||||||
} else {
|
saved = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
||||||
setDarkTheme();
|
|
||||||
}
|
}
|
||||||
|
applyTheme(saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setDarkTheme() {
|
$('theme-btn').addEventListener('click', function () {
|
||||||
document.body.classList.add('dark-theme');
|
applyTheme(document.documentElement.classList.contains('theme-light') ? 'dark' : 'light');
|
||||||
document.body.classList.remove('light-theme');
|
|
||||||
document.getElementById('theme-icon').textContent = darkIcon;
|
|
||||||
localStorage.setItem(themeKey, 'dark');
|
|
||||||
}
|
|
||||||
|
|
||||||
function setLightTheme() {
|
|
||||||
document.body.classList.add('light-theme');
|
|
||||||
document.body.classList.remove('dark-theme');
|
|
||||||
document.getElementById('theme-icon').textContent = lightIcon;
|
|
||||||
localStorage.setItem(themeKey, 'light');
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleTheme() {
|
|
||||||
if (document.body.classList.contains('dark-theme')) {
|
|
||||||
setLightTheme();
|
|
||||||
} else {
|
|
||||||
setDarkTheme();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleInterval() {
|
|
||||||
const intervalGroup = document.getElementById('interval-group');
|
|
||||||
const loopChecked = document.getElementById('loop').checked;
|
|
||||||
intervalGroup.style.display = loopChecked ? 'block' : 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
function addLog(message, type = 'info') {
|
|
||||||
const logsContainer = document.getElementById('logs');
|
|
||||||
const logEntry = document.createElement('p');
|
|
||||||
logEntry.className = `log-${type}`;
|
|
||||||
const timestamp = new Date().toLocaleTimeString('zh-CN');
|
|
||||||
logEntry.innerHTML = `<span class="log-time">[${timestamp}]</span> ${message}`;
|
|
||||||
logsContainer.appendChild(logEntry);
|
|
||||||
logsContainer.scrollTop = logsContainer.scrollHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearLogs() {
|
|
||||||
const logsContainer = document.getElementById('logs');
|
|
||||||
logsContainer.innerHTML = '<p class="log-info">系统已就绪,等待操作...</p>';
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateCharCount() {
|
|
||||||
const textarea = document.getElementById('text');
|
|
||||||
const count = textarea.value.length;
|
|
||||||
document.querySelector('.char-count').textContent = `${count}/500`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function speak() {
|
|
||||||
const text = document.getElementById('text').value.trim();
|
|
||||||
const voice = document.getElementById('voice').value;
|
|
||||||
const loop = document.getElementById('loop').checked;
|
|
||||||
const interval = document.getElementById('interval').value;
|
|
||||||
|
|
||||||
if (!text) {
|
|
||||||
addLog('请输入广播内容', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
addLog('开始处理广播请求...', 'info');
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('text', text);
|
|
||||||
formData.append('voice', voice);
|
|
||||||
formData.append('loop', loop);
|
|
||||||
formData.append('interval', interval);
|
|
||||||
|
|
||||||
addLog(`正在生成语音,使用声音: ${voice}`, 'info');
|
|
||||||
|
|
||||||
if (loop) {
|
|
||||||
addLog(`循环模式已启用,间隔时间: ${interval}秒`, 'info');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
addLog('正在调用语音合成服务...', 'info');
|
|
||||||
const response = await fetch('/speak', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
/* ---------- 日志 ---------- */
|
||||||
|
|
||||||
|
function stamp() {
|
||||||
|
return new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function log(message, kind) {
|
||||||
|
var line = document.createElement('div');
|
||||||
|
line.className = 'log' + (kind ? ' log-' + kind : '');
|
||||||
|
var time = document.createElement('span');
|
||||||
|
time.className = 'log-time';
|
||||||
|
time.textContent = stamp();
|
||||||
|
var body = document.createElement('span');
|
||||||
|
body.textContent = message;
|
||||||
|
line.appendChild(time);
|
||||||
|
line.appendChild(body);
|
||||||
|
elLogs.appendChild(line);
|
||||||
|
elLogs.scrollTop = elLogs.scrollHeight;
|
||||||
|
while (elLogs.childElementCount > 80) elLogs.removeChild(elLogs.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
$('clear-logs').addEventListener('click', function () { elLogs.innerHTML = ''; });
|
||||||
|
|
||||||
|
/* ---------- 抽屉 ---------- */
|
||||||
|
|
||||||
|
function drawer(id) {
|
||||||
|
var box = $(id);
|
||||||
|
var head = box.querySelector('.drawer-head');
|
||||||
|
var body = box.querySelector('.drawer-body');
|
||||||
|
head.addEventListener('click', function () {
|
||||||
|
var open = body.classList.toggle('open');
|
||||||
|
head.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
open: function () { body.classList.add('open'); head.setAttribute('aria-expanded', 'true'); },
|
||||||
|
body: body
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var history = drawer('history');
|
||||||
|
var status = drawer('status');
|
||||||
|
|
||||||
|
/* ---------- 广播状态 ---------- */
|
||||||
|
|
||||||
|
var live = false; // 喇叭是否正在响
|
||||||
|
var busy = false; // 是否正在合成
|
||||||
|
|
||||||
|
function setLive(on, info) {
|
||||||
|
live = on;
|
||||||
|
document.body.classList.toggle('is-live', on);
|
||||||
|
elGo.textContent = busy ? '正在合成…' : (on ? '停止广播' : '开始广播');
|
||||||
|
elGo.disabled = busy;
|
||||||
|
elGo.classList.toggle('is-stop', on && !busy);
|
||||||
|
|
||||||
|
if (on) {
|
||||||
|
elStatusText.textContent = '正在播报';
|
||||||
|
elStatusMeta.textContent = describe(info);
|
||||||
|
} else if (!busy) {
|
||||||
|
elStatusText.textContent = '待机';
|
||||||
|
elStatusMeta.textContent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function describe(info) {
|
||||||
|
if (!info || !info.playing) return '';
|
||||||
|
var who = info.display_name ? ',' + info.display_name : '';
|
||||||
|
return info.mode === 'loop'
|
||||||
|
? '循环播报,每 ' + info.interval + ' 秒一次' + who
|
||||||
|
: '播一遍' + who;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBusy(on) {
|
||||||
|
busy = on;
|
||||||
|
elGo.disabled = on;
|
||||||
|
elGo.textContent = on ? '正在合成…' : (live ? '停止广播' : '开始广播');
|
||||||
|
elGo.classList.toggle('is-stop', !on && live);
|
||||||
|
elGo.classList.toggle('is-busy', on);
|
||||||
|
if (on) { elStatusText.textContent = '正在合成'; elStatusMeta.textContent = '等几秒'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
try {
|
||||||
|
var r = await fetch('/status');
|
||||||
|
var info = await r.json();
|
||||||
|
if (info.playing !== live && !busy) setLive(info.playing, info);
|
||||||
|
else if (info.playing) elStatusMeta.textContent = describe(info);
|
||||||
|
} catch (e) { /* 网络抖动就跳过这一轮 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 内容与音色 ---------- */
|
||||||
|
|
||||||
|
function autoGrow() {
|
||||||
|
elText.style.height = 'auto';
|
||||||
|
elText.style.height = Math.min(elText.scrollHeight, 420) + 'px';
|
||||||
|
elCount.textContent = elText.value.length + ' / 500';
|
||||||
|
}
|
||||||
|
elText.addEventListener('input', autoGrow);
|
||||||
|
|
||||||
|
elLoop.addEventListener('change', function () {
|
||||||
|
elIntervalWrap.hidden = !elLoop.checked;
|
||||||
|
});
|
||||||
|
elInterval.addEventListener('input', function () {
|
||||||
|
elIntervalOut.textContent = '每 ' + elInterval.value + ' 秒';
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---------- 广播 / 停止 ---------- */
|
||||||
|
|
||||||
|
elGo.addEventListener('click', function () {
|
||||||
|
if (live) stop(); else speak();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function speak() {
|
||||||
|
var text = elText.value.trim();
|
||||||
|
if (!text) { flash('先写一句要播的话'); elText.focus(); return; }
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
log('开始合成,音色:' + elVoice.value);
|
||||||
|
if (elLoop.checked) log('循环播报,每 ' + elInterval.value + ' 秒一次');
|
||||||
|
|
||||||
|
var form = new FormData();
|
||||||
|
form.append('text', text);
|
||||||
|
form.append('voice', elVoice.value);
|
||||||
|
form.append('loop', elLoop.checked);
|
||||||
|
form.append('interval', elInterval.value);
|
||||||
|
|
||||||
|
try {
|
||||||
|
var r = await fetch('/speak', { method: 'POST', body: form });
|
||||||
|
var result = await r.json();
|
||||||
|
setBusy(false);
|
||||||
if (result.status === 'ok') {
|
if (result.status === 'ok') {
|
||||||
addLog('语音合成成功,开始播放...', 'success');
|
log('已开始广播', 'ok');
|
||||||
loadFiles();
|
loadFiles();
|
||||||
|
poll();
|
||||||
} else {
|
} else {
|
||||||
addLog(`播放失败: ${result.message || '未知错误'}`, 'error');
|
fail(result.message || '合成失败');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (e) {
|
||||||
addLog(`请求失败: ${error.message}`, 'error');
|
setBusy(false);
|
||||||
|
fail(e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stopPlay() {
|
async function stop() {
|
||||||
addLog('正在停止播放...', 'info');
|
|
||||||
try {
|
try {
|
||||||
await fetch('/stop');
|
await fetch('/stop');
|
||||||
addLog('播放已停止', 'success');
|
log('已停止播报');
|
||||||
} catch (error) {
|
setLive(false);
|
||||||
addLog(`停止失败: ${error.message}`, 'error');
|
} catch (e) { fail(e.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fail(message) {
|
||||||
|
log('没成功:' + message, 'err');
|
||||||
|
elStatusText.textContent = '上次没成功';
|
||||||
|
elStatusMeta.textContent = message;
|
||||||
|
status.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
var flashTimer = null;
|
||||||
|
function flash(message) {
|
||||||
|
log(message, 'err');
|
||||||
|
elText.classList.add('nudge');
|
||||||
|
clearTimeout(flashTimer);
|
||||||
|
flashTimer = setTimeout(function () { elText.classList.remove('nudge'); }, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 语音列表 ---------- */
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
return String(str).replace(/&/g, '&').replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function when(ts) {
|
||||||
|
var d = new Date(ts * 1000);
|
||||||
|
var now = new Date();
|
||||||
|
var sameDay = d.toDateString() === now.toDateString();
|
||||||
|
var hm = d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
return sameDay ? hm : (d.getMonth() + 1) + '-' + d.getDate() + ' ' + hm;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadFiles() {
|
async function loadFiles() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/files');
|
var r = await fetch('/files');
|
||||||
const result = await response.json();
|
var result = await r.json();
|
||||||
renderFiles(result.files);
|
renderFiles(result.files || []);
|
||||||
} catch (error) {
|
} catch (e) {
|
||||||
addLog(`加载文件列表失败: ${error.message}`, 'error');
|
log('读不到语音列表:' + e.message, 'err');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderFiles(files) {
|
function renderFiles(files) {
|
||||||
const container = document.getElementById('files-list');
|
elHistoryCount.textContent = files.length + ' 条';
|
||||||
|
elFiles.innerHTML = '';
|
||||||
|
|
||||||
if (!files || files.length === 0) {
|
if (!files.length) {
|
||||||
container.innerHTML = `
|
var empty = document.createElement('p');
|
||||||
<div class="empty-state">
|
empty.className = 'empty';
|
||||||
<span class="empty-icon">📭</span>
|
empty.textContent = '还没有播过的内容。上面写一句,点「开始广播」就有了。';
|
||||||
<p>暂无语音文件</p>
|
elFiles.appendChild(empty);
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = files.map(file => `
|
files.forEach(function (file) { elFiles.appendChild(fileRow(file)); });
|
||||||
<div class="file-item ${file.is_favorite ? 'file-item-favorite' : ''}">
|
|
||||||
<div class="file-info">
|
|
||||||
<div class="file-icon">🎵</div>
|
|
||||||
<div class="file-details">
|
|
||||||
<div class="file-name-container">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="file-name-input"
|
|
||||||
value="${escapeHtml(file.display_name)}"
|
|
||||||
data-filename="${file.filename}"
|
|
||||||
onblur="saveFileName(this)"
|
|
||||||
onkeyup="handleFileNameKeyup(event, this)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="file-meta">
|
|
||||||
<span class="file-voice">${file.voice || '未知声音'}</span>
|
|
||||||
<span class="file-date">${formatDate(file.created_at)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="file-actions">
|
|
||||||
<button class="file-btn file-btn-favorite ${file.is_favorite ? 'active' : ''}" onclick="toggleFavorite('${file.filename}')" title="${file.is_favorite ? '取消收藏' : '收藏'}">
|
|
||||||
${file.is_favorite ? '⭐' : '☆'}
|
|
||||||
</button>
|
|
||||||
<button class="file-btn file-btn-play" onclick="playFile('${file.filename}')" title="播放">
|
|
||||||
▶️
|
|
||||||
</button>
|
|
||||||
<button class="file-btn file-btn-loop" onclick="playFileLoop('${file.filename}')" title="循环播放">
|
|
||||||
🔄
|
|
||||||
</button>
|
|
||||||
<button class="file-btn file-btn-delete" onclick="deleteFile('${file.filename}', '${escapeHtml(file.display_name)}')" title="删除">
|
|
||||||
🗑️
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(str) {
|
function fileRow(file) {
|
||||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
var row = document.createElement('div');
|
||||||
}
|
row.className = 'row' + (file.is_favorite ? ' is-fav' : '');
|
||||||
|
row.dataset.filename = file.filename;
|
||||||
|
|
||||||
function formatDate(timestamp) {
|
var name = document.createElement('input');
|
||||||
const date = new Date(timestamp * 1000);
|
name.className = 'row-name';
|
||||||
return date.toLocaleString('zh-CN', {
|
name.type = 'text';
|
||||||
month: '2-digit',
|
name.value = file.display_name;
|
||||||
day: '2-digit',
|
name.dataset.filename = file.filename;
|
||||||
hour: '2-digit',
|
name.dataset.old = file.display_name;
|
||||||
minute: '2-digit'
|
name.setAttribute('aria-label', '这一条的名字,可以直接改');
|
||||||
|
|
||||||
|
var meta = document.createElement('div');
|
||||||
|
meta.className = 'row-meta';
|
||||||
|
var voice = document.createElement('span');
|
||||||
|
voice.textContent = file.voice || '未知声音';
|
||||||
|
var time = document.createElement('span');
|
||||||
|
time.textContent = when(file.created_at);
|
||||||
|
meta.appendChild(voice);
|
||||||
|
meta.appendChild(time);
|
||||||
|
|
||||||
|
var actions = document.createElement('div');
|
||||||
|
actions.className = 'row-actions';
|
||||||
|
actions.appendChild(action('播一遍', 'play'));
|
||||||
|
actions.appendChild(action('循环', 'loop'));
|
||||||
|
actions.appendChild(action('删除', 'del'));
|
||||||
|
actions.appendChild(favButton(file.is_favorite));
|
||||||
|
|
||||||
|
row.appendChild(name);
|
||||||
|
row.appendChild(meta);
|
||||||
|
row.appendChild(actions);
|
||||||
|
|
||||||
|
name.addEventListener('blur', function () { rename(name); });
|
||||||
|
name.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Enter') name.blur();
|
||||||
|
if (e.key === 'Escape') { name.value = name.dataset.old; name.blur(); }
|
||||||
});
|
});
|
||||||
|
actions.querySelector('[data-act="play"]').addEventListener('click', function () { play(file.filename, false); });
|
||||||
|
actions.querySelector('[data-act="loop"]').addEventListener('click', function () { play(file.filename, true); });
|
||||||
|
actions.querySelector('.row-fav').addEventListener('click', function () { favorite(file.filename); });
|
||||||
|
actions.querySelector('[data-act="del"]').addEventListener('click', function () { confirmDelete(row, file); });
|
||||||
|
|
||||||
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function playFile(filename) {
|
function action(label, act) {
|
||||||
const loop = document.getElementById('loop').checked;
|
var b = document.createElement('button');
|
||||||
const interval = document.getElementById('interval').value;
|
b.type = 'button';
|
||||||
|
b.className = 'act';
|
||||||
|
b.dataset.act = act;
|
||||||
|
b.textContent = label;
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
addLog(`正在播放已保存的语音...`, 'info');
|
function favButton(isFav) {
|
||||||
|
var b = action(isFav ? '已收藏' : '收藏', 'fav');
|
||||||
const formData = new FormData();
|
b.classList.add('row-fav');
|
||||||
formData.append('filename', filename);
|
if (isFav) b.classList.add('on');
|
||||||
formData.append('loop', loop);
|
return b;
|
||||||
formData.append('interval', interval);
|
}
|
||||||
|
|
||||||
|
async function play(filename, loop) {
|
||||||
|
var form = new FormData();
|
||||||
|
form.append('filename', filename);
|
||||||
|
form.append('loop', loop);
|
||||||
|
form.append('interval', elInterval.value);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/play-file', {
|
var r = await fetch('/play-file', { method: 'POST', body: form });
|
||||||
method: 'POST',
|
var result = await r.json();
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.status === 'ok') {
|
if (result.status === 'ok') {
|
||||||
addLog(result.message, 'success');
|
log(result.message);
|
||||||
|
poll();
|
||||||
} else {
|
} else {
|
||||||
addLog(`播放失败: ${result.message}`, 'error');
|
fail(result.message || '播不了这一条');
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
addLog(`播放失败: ${error.message}`, 'error');
|
|
||||||
}
|
}
|
||||||
|
} catch (e) { fail(e.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function playFileLoop(filename) {
|
async function rename(input) {
|
||||||
const interval = document.getElementById('interval').value;
|
var next = input.value.trim();
|
||||||
|
if (!next || next === input.dataset.old) { input.value = input.dataset.old; return; }
|
||||||
addLog(`正在循环播放已保存的语音...`, 'info');
|
var form = new FormData();
|
||||||
|
form.append('filename', input.dataset.filename);
|
||||||
const formData = new FormData();
|
form.append('new_name', next);
|
||||||
formData.append('filename', filename);
|
|
||||||
formData.append('loop', true);
|
|
||||||
formData.append('interval', interval);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/play-file', {
|
var r = await fetch('/rename-file', { method: 'POST', body: form });
|
||||||
method: 'POST',
|
var result = await r.json();
|
||||||
body: formData
|
if (result.status === 'ok') { input.dataset.old = next; log('改名成功'); }
|
||||||
});
|
else { input.value = input.dataset.old; fail(result.message); }
|
||||||
const result = await response.json();
|
} catch (e) { input.value = input.dataset.old; fail(e.message); }
|
||||||
|
|
||||||
if (result.status === 'ok') {
|
|
||||||
addLog(`${result.message},间隔时间: ${interval}秒`, 'success');
|
|
||||||
} else {
|
|
||||||
addLog(`播放失败: ${result.message}`, 'error');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
addLog(`播放失败: ${error.message}`, 'error');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveFileName(input) {
|
async function favorite(filename) {
|
||||||
const filename = input.dataset.filename;
|
var form = new FormData();
|
||||||
const newName = input.value.trim();
|
form.append('filename', filename);
|
||||||
|
|
||||||
if (!newName) {
|
|
||||||
input.value = input.oldValue || filename;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newName === input.oldValue) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
var r = await fetch('/toggle-favorite', { method: 'POST', body: form });
|
||||||
formData.append('filename', filename);
|
var result = await r.json();
|
||||||
formData.append('new_name', newName);
|
if (result.status === 'ok') { log(result.message); loadFiles(); }
|
||||||
|
else { fail(result.message); }
|
||||||
|
} catch (e) { fail(e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch('/rename-file', {
|
function confirmDelete(row, file) {
|
||||||
method: 'POST',
|
var actions = row.querySelector('.row-actions');
|
||||||
body: formData
|
if (row.dataset.confirming) return;
|
||||||
|
row.dataset.confirming = '1';
|
||||||
|
var kept = actions.innerHTML;
|
||||||
|
actions.innerHTML = '';
|
||||||
|
|
||||||
|
var ask = document.createElement('span');
|
||||||
|
ask.className = 'confirm-text';
|
||||||
|
ask.textContent = '删掉这条?';
|
||||||
|
|
||||||
|
var yes = action('删除', 'yes');
|
||||||
|
yes.classList.add('danger');
|
||||||
|
var no = action('取消', 'no');
|
||||||
|
|
||||||
|
yes.addEventListener('click', function () { remove(file); });
|
||||||
|
no.addEventListener('click', function () {
|
||||||
|
delete row.dataset.confirming;
|
||||||
|
actions.innerHTML = kept;
|
||||||
|
rebind(actions, row, file);
|
||||||
});
|
});
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.status === 'ok') {
|
actions.appendChild(ask);
|
||||||
addLog(`文件已重命名为: ${newName}`, 'success');
|
actions.appendChild(yes);
|
||||||
} else {
|
actions.appendChild(no);
|
||||||
addLog(`重命名失败: ${result.message}`, 'error');
|
yes.focus();
|
||||||
input.value = input.oldValue;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
addLog(`重命名失败: ${error.message}`, 'error');
|
|
||||||
input.value = input.oldValue;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFileNameKeyup(event, input) {
|
function rebind(actions, row, file) {
|
||||||
if (!input.oldValue) {
|
actions.querySelector('[data-act="play"]').addEventListener('click', function () { play(file.filename, false); });
|
||||||
input.oldValue = input.value;
|
actions.querySelector('[data-act="loop"]').addEventListener('click', function () { play(file.filename, true); });
|
||||||
|
actions.querySelector('.row-fav').addEventListener('click', function () { favorite(file.filename); });
|
||||||
|
actions.querySelector('[data-act="del"]').addEventListener('click', function () { confirmDelete(row, file); });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.key === 'Enter') {
|
async function remove(file) {
|
||||||
input.blur();
|
var form = new FormData();
|
||||||
} else if (event.key === 'Escape') {
|
form.append('filename', file.filename);
|
||||||
input.value = input.oldValue;
|
|
||||||
input.blur();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleFavorite(filename) {
|
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
var r = await fetch('/delete-file', { method: 'POST', body: form });
|
||||||
formData.append('filename', filename);
|
var result = await r.json();
|
||||||
|
if (result.status === 'ok') { log('已删除'); loadFiles(); poll(); }
|
||||||
const response = await fetch('/toggle-favorite', {
|
else { fail(result.message); }
|
||||||
method: 'POST',
|
} catch (e) { fail(e.message); }
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.status === 'ok') {
|
|
||||||
addLog(result.message, 'success');
|
|
||||||
loadFiles();
|
|
||||||
} else {
|
|
||||||
addLog(`操作失败: ${result.message}`, 'error');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
addLog(`操作失败: ${error.message}`, 'error');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteFile(filename, displayName) {
|
/* ---------- 启动 ---------- */
|
||||||
if (!confirm(`确定要删除文件 "${displayName}" 吗?`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('filename', filename);
|
|
||||||
|
|
||||||
const response = await fetch('/delete-file', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.status === 'ok') {
|
|
||||||
addLog(`文件已删除: ${displayName}`, 'success');
|
|
||||||
loadFiles();
|
|
||||||
} else {
|
|
||||||
addLog(`删除失败: ${result.message}`, 'error');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
addLog(`删除失败: ${error.message}`, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('text').addEventListener('input', updateCharCount);
|
|
||||||
document.getElementById('interval').addEventListener('input', function() {
|
|
||||||
document.getElementById('interval-value').textContent = this.value;
|
|
||||||
});
|
|
||||||
|
|
||||||
initTheme();
|
initTheme();
|
||||||
|
autoGrow();
|
||||||
loadFiles();
|
loadFiles();
|
||||||
</script>
|
poll();
|
||||||
|
setInterval(poll, 3000);
|
||||||
|
log('就绪');
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Reference in new issue
Block a user