喇叭
+语音文件列表
-暂无语音文件
-运行日志
-系统已就绪,等待操作...
-From b081bba33cb56d6485047d44e1e9ebbdeac1c5db Mon Sep 17 00:00:00 2001 From: Cx330 <1487537121@qq.com> Date: Tue, 13 Oct 2026 18:13:47 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=BE=AE=E8=BD=AFedge-tts?= =?UTF-8?q?=E6=8A=A5403=E7=9A=84bug=EF=BC=8C=E4=BC=98=E5=8C=96UI=E7=95=8C?= =?UTF-8?q?=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 42 +- app.py | 185 ++++--- patch_edge_tts_403.py | 184 +++++++ player.sh | 2 +- static/style.css | 1142 +++++++++++++++++------------------------ templates/index.html | 857 ++++++++++++++++--------------- 6 files changed, 1242 insertions(+), 1170 deletions(-) create mode 100644 patch_edge_tts_403.py diff --git a/README.md b/README.md index 6b95266..fb9989e 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Web广播系统 - 吆喝用的喇叭(i人专用) - **循环播放**:支持设置间隔时间的循环播放模式 - **语音文件管理**:播放历史、收藏、重命名、删除 - **主题切换**:支持深色/浅色主题 -- **实时日志**:显示操作日志和状态信息 +- **状态条与日志**:一条状态随时说明喇叭在做什么(待机 / 正在合成 / 正在播报 / 上次没成功),需要时展开看完整日志 ## 支持的声音 @@ -40,8 +40,20 @@ pip install flask edge-tts # 安装音频播放工具 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/ ├── app.py # Flask 应用主文件 ├── player.sh # 循环播放脚本 +├── patch_edge_tts_403.py # 修复 edge-tts 合成 403 的补丁脚本 ├── templates/ │ └── index.html # 前端页面模板 ├── static/ @@ -99,12 +112,39 @@ PA_horn_speaker/ | `/rename-file` | POST | 重命名文件 | | `/toggle-favorite` | 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 服务) 2. 音频输出使用 ALSA 设备 `plughw:audiocodec,0`,可根据实际设备调整 3. 缓存目录最多保存10个文件,收藏的文件不会被自动清理 +4. 合成失败会自动重试 3 次(新端点对同一 IP 有限流),仍失败则返回错误提示,不会再把空音频文件当成正常广播;缓存里已损坏(0 字节)的文件也不会被播放 ## APK 文件 diff --git a/app.py b/app.py index 258cd32..c7280c9 100644 --- a/app.py +++ b/app.py @@ -3,6 +3,7 @@ import subprocess import os import uuid import json +import time app = Flask(__name__) @@ -32,6 +33,52 @@ voices = { 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(): if not os.path.exists(METADATA_FILE): @@ -93,7 +140,6 @@ def index(): @app.route('/speak', methods=['POST']) def speak(): - global current_process text = request.form.get('text') voice = request.form.get('voice') @@ -105,25 +151,55 @@ def speak(): 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" filepath = os.path.join(CACHE_DIR, filename) voice_name = voices.get(voice, 'zh-CN-YunxiNeural') - subprocess.run([ - 'python3', - '-m', - 'edge_tts', - '--voice', - voice_name, - '--text', - text, - '--write-media', - filepath - ]) + # 合成音频。新端点会对同一 IP 限流(429),故失败后重试几次。 + ok = False + err_tail = '' + for attempt in range(3): + proc = subprocess.run([ + 'python3', + '-m', + 'edge_tts', + '--voice', + voice_name, + '--text', + text, + '--write-media', + 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() - display_name = text[:30] + '...' if len(text) > 30 else text + if not ok: + # 合成失败:清掉可能的空文件、不写元数据,避免把坏文件当正常广播循环播放 + 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] = { 'display_name': display_name, 'text': text, @@ -132,40 +208,15 @@ def speak(): } save_metadata(metadata) - if current_process: - 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 - ]) + _start_playback(filename, loop, interval) return jsonify({'status': 'ok', 'filename': filename}) @app.route('/stop') def stop(): - global current_process - if current_process: - current_process.kill() - current_process = None - - subprocess.run(['pkill', '-9', 'mpg123']) + _stop_playback() return jsonify({'status': 'stopped'}) @@ -195,7 +246,6 @@ def list_files(): @app.route('/play-file', methods=['POST']) def play_file(): - global current_process filename = request.form.get('filename') loop = request.form.get('loop') == 'true' @@ -206,30 +256,10 @@ def play_file(): filepath = os.path.join(CACHE_DIR, filename) - if not os.path.exists(filepath): - return jsonify({'status': 'error', 'message': '文件不存在'}) + if not os.path.exists(filepath) or os.path.getsize(filepath) == 0: + return jsonify({'status': 'error', 'message': '文件不存在或已损坏'}) - if current_process: - 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 - ]) + _start_playback(filename, loop, interval) metadata = get_metadata() display_name = metadata.get(filename, {}).get('display_name', filename) @@ -280,7 +310,6 @@ def toggle_favorite(): @app.route('/delete-file', methods=['POST']) def delete_file(): - global current_process filename = request.form.get('filename') @@ -292,14 +321,7 @@ def delete_file(): if not os.path.exists(filepath): return jsonify({'status': 'error', 'message': '文件不存在'}) - if current_process: - try: - current_process.kill() - current_process = None - except: - pass - - subprocess.run(['pkill', '-9', 'mpg123']) + _stop_playback() os.remove(filepath) @@ -311,6 +333,21 @@ def delete_file(): 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 not os.path.exists(CACHE_DIR): os.makedirs(CACHE_DIR) diff --git a/patch_edge_tts_403.py b/patch_edge_tts_403.py new file mode 100644 index 0000000..fd32ea5 --- /dev/null +++ b/patch_edge_tts_403.py @@ -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()) diff --git a/player.sh b/player.sh index 3553f24..9c3e9f3 100644 --- a/player.sh +++ b/player.sh @@ -5,6 +5,6 @@ INTERVAL=${2:-5} while true do - mpg123 -o alsa -a plughw:audiocodec,0 "$FILE" + mpg123 -f 131072 -o alsa -a plughw:audiocodec,0 "$FILE" sleep "$INTERVAL" done \ No newline at end of file diff --git a/static/style.css b/static/style.css index 5990850..30ce2c0 100644 --- a/static/style.css +++ b/static/style.css @@ -1,689 +1,467 @@ +/* 喇叭 — 控制界面 + 概念:颜色 = 喇叭是否在响。待机时整页无彩,只有正在广播时才出现唯一的朱红。 */ + :root { - --primary-color: #0078ff; - --primary-hover: #0066d4; - --danger-color: #ff3b30; - --danger-hover: #e6352a; - --secondary-color: #6b7280; - --secondary-hover: #4b5563; - - --bg-primary: #101010; - --bg-secondary: #1b1b1b; - --bg-tertiary: #252525; - --bg-input: #252525; - - --text-primary: #ffffff; - --text-secondary: #a0a0a0; - --text-muted: #6b7280; - - --border-color: #333333; - --border-radius: 12px; - --border-radius-lg: 16px; - - --shadow: 0 4px 20px rgba(0, 0, 0, 0.3); - --shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.4); - - --transition: all 0.3s ease; + --paper: #121614; + --surface: #1A1F1C; + --ink: #ECEFEA; + --ink-soft: #9AA29C; + --line: #2C332E; + --live: #FF5A3D; + --live-ink: #121614; + --ink-hover: #DCE0D9; + --live-hover: #FF7059; + --slab-shadow: none; + --slab-ring: 1px solid var(--line); + color-scheme: dark; } -* { - margin: 0; - padding: 0; - box-sizing: border-box; +:root.theme-light { + --paper: #F0F1EE; + --surface: #FFFFFF; + --ink: #191C1A; + --ink-soft: #5E645F; + --line: #D9DBD6; + --live: #C8321E; + --live-ink: #FFFFFF; + --ink-hover: #2E332F; + --live-hover: #B02A18; + --slab-shadow: 0 20px 44px -28px rgba(25, 28, 26, 0.45); + --slab-ring: 1px solid transparent; + color-scheme: light; } +* { box-sizing: border-box; margin: 0; padding: 0; } + +html { -webkit-text-size-adjust: 100%; } + body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; - background: var(--bg-primary); - color: var(--text-primary); - min-height: 100vh; - padding: 20px; - transition: var(--transition); -} - -body.light-theme { - --bg-primary: #f5f5f5; - --bg-secondary: #ffffff; - --bg-tertiary: #f0f0f0; - --bg-input: #ffffff; - - --text-primary: #1a1a1a; - --text-secondary: #4a4a4a; - --text-muted: #888888; - - --border-color: #e0e0e0; - --shadow: 0 4px 20px rgba(0, 0, 0, 0.08); - --shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.12); -} - -.container { - max-width: 600px; - margin: 0 auto; - display: flex; - flex-direction: column; - gap: 20px; -} - -.header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 10px; -} - -.header h1 { - font-size: 1.8rem; - font-weight: 600; - background: linear-gradient(135deg, var(--primary-color), #00d4ff); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; -} - -.theme-toggle { - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 50%; - width: 44px; - height: 44px; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - font-size: 1.2rem; - transition: var(--transition); -} - -.theme-toggle:hover { - background: var(--bg-tertiary); - transform: rotate(180deg); -} - -.main-content { - background: var(--bg-secondary); - border-radius: var(--border-radius-lg); - padding: 24px; - box-shadow: var(--shadow); -} - -.form-group { - margin-bottom: 20px; -} - -.form-group label { - display: block; - margin-bottom: 8px; - font-size: 0.9rem; - font-weight: 500; - color: var(--text-secondary); -} - -textarea { - width: 100%; - height: 140px; - background: var(--bg-input); - border: 1px solid var(--border-color); - border-radius: var(--border-radius); - padding: 14px; - font-size: 1rem; - color: var(--text-primary); - resize: none; - transition: var(--transition); - font-family: inherit; -} - -textarea:focus { - outline: none; - border-color: var(--primary-color); - box-shadow: 0 0 0 3px rgba(0, 120, 255, 0.1); -} - -textarea::placeholder { - color: var(--text-muted); -} - -.char-count { - display: block; - text-align: right; - margin-top: 6px; - font-size: 0.8rem; - color: var(--text-muted); -} - -select { - width: 100%; - background: var(--bg-input); - border: 1px solid var(--border-color); - border-radius: var(--border-radius); - padding: 12px 14px; - font-size: 1rem; - color: var(--text-primary); - cursor: pointer; - transition: var(--transition); - appearance: none; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 12px center; -} - -select:focus { - outline: none; - border-color: var(--primary-color); - box-shadow: 0 0 0 3px rgba(0, 120, 255, 0.1); -} - -select option { - background: var(--bg-secondary); - color: var(--text-primary); - padding: 10px; -} - -.checkbox-label { - display: flex; - align-items: center; - gap: 10px; - cursor: pointer; - padding: 12px; - background: var(--bg-tertiary); - border-radius: var(--border-radius); - transition: var(--transition); -} - -.checkbox-label:hover { - background: var(--bg-input); -} - -.checkbox-label input[type="checkbox"] { - width: 20px; - height: 20px; - cursor: pointer; - accent-color: var(--primary-color); -} - -.checkbox-label span { - font-size: 1rem; - color: var(--text-primary); - font-weight: 500; -} - -.interval-group { - animation: fadeIn 0.3s ease; -} - -@keyframes fadeIn { - from { - opacity: 0; - transform: translateY(-10px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.interval-input { - display: flex; - align-items: center; - gap: 12px; -} - -.interval-input input[type="range"] { - flex: 1; - height: 8px; - background: var(--bg-tertiary); - border-radius: 4px; - outline: none; - cursor: pointer; - appearance: none; -} - -.interval-input input[type="range"]::-webkit-slider-thumb { - appearance: none; - width: 20px; - height: 20px; - background: var(--primary-color); - border-radius: 50%; - cursor: pointer; - transition: var(--transition); -} - -.interval-input input[type="range"]::-webkit-slider-thumb:hover { - transform: scale(1.1); -} - -.interval-input span#interval-value { - font-size: 1.2rem; - font-weight: 600; - color: var(--primary-color); - min-width: 30px; -} - -.interval-input .unit { - color: var(--text-secondary); - font-size: 0.9rem; -} - -.button-group { - display: flex; - gap: 12px; - margin-top: 24px; -} - -.btn { - flex: 1; - padding: 14px 20px; - border: none; - border-radius: var(--border-radius); - font-size: 1rem; - font-weight: 600; - cursor: pointer; - transition: var(--transition); - display: flex; - align-items: center; - justify-content: center; - gap: 8px; -} - -.btn-primary { - background: linear-gradient(135deg, var(--primary-color), #0099ff); - color: white; -} - -.btn-primary:hover { - background: linear-gradient(135deg, var(--primary-hover), #0088e6); - transform: translateY(-2px); - box-shadow: 0 4px 15px rgba(0, 120, 255, 0.4); -} - -.btn-danger { - background: linear-gradient(135deg, var(--danger-color), #ff5a50); - color: white; -} - -.btn-danger:hover { - background: linear-gradient(135deg, var(--danger-hover), #ff4a40); - transform: translateY(-2px); - box-shadow: 0 4px 15px rgba(255, 59, 48, 0.4); -} - -.btn-secondary { - background: var(--bg-tertiary); - color: var(--text-secondary); -} - -.btn-secondary:hover { - background: var(--secondary-hover); - color: var(--text-primary); -} - -.btn-small { - padding: 6px 12px; - font-size: 0.8rem; -} - -.btn-icon { - font-size: 1.1rem; -} - -.files-container { - background: var(--bg-secondary); - border-radius: var(--border-radius-lg); - padding: 20px; - box-shadow: var(--shadow); -} - -.files-header { - display: flex; - align-items: center; - gap: 10px; - margin-bottom: 16px; -} - -.refresh-btn { - padding: 8px; - min-width: auto; -} - -.files-header h3 { - font-size: 1.1rem; - font-weight: 600; - color: var(--text-primary); -} - -.files-list { - max-height: 300px; - overflow-y: auto; -} - -.files-list::-webkit-scrollbar { - width: 6px; -} - -.files-list::-webkit-scrollbar-track { - background: var(--bg-tertiary); - border-radius: 3px; -} - -.files-list::-webkit-scrollbar-thumb { - background: var(--border-color); - border-radius: 3px; -} - -.empty-state { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 30px; - color: var(--text-muted); -} - -.empty-icon { - font-size: 2.5rem; - margin-bottom: 10px; -} - -.empty-state p { - font-size: 0.9rem; -} - -.file-item { - display: flex; - align-items: center; - justify-content: space-between; - background: var(--bg-tertiary); - border-radius: var(--border-radius); - padding: 12px; - margin-bottom: 10px; - transition: var(--transition); -} - -.file-item-favorite { - border: 2px solid #fbbf24; -} - -.file-item:hover { - background: var(--bg-input); -} - -.file-item-favorite:hover { - border-color: #f59e0b; -} - -.file-info { - display: flex; - align-items: center; - gap: 12px; - flex: 1; - min-width: 0; -} - -.file-icon { - font-size: 1.4rem; - flex-shrink: 0; -} - -.file-details { - flex: 1; - min-width: 0; -} - -.file-name-container { - margin-bottom: 4px; -} - -.file-name-input { - width: 100%; - background: transparent; - border: none; - outline: none; - color: var(--text-primary); - font-size: 0.95rem; - font-weight: 500; - padding: 4px 6px; - border-radius: 4px; - transition: var(--transition); -} - -.file-name-input:hover { - background: var(--bg-input); -} - -.file-name-input:focus { - background: var(--bg-input); - box-shadow: 0 0 0 2px var(--primary-color); -} - -.file-meta { - display: flex; - gap: 12px; - font-size: 0.8rem; - color: var(--text-muted); -} - -.file-voice { - padding: 2px 6px; - background: var(--bg-input); - border-radius: 4px; -} - -.file-actions { - display: flex; - gap: 8px; - flex-shrink: 0; -} - -.file-btn { - width: 36px; - height: 36px; - border: none; - border-radius: 8px; - background: var(--bg-input); - cursor: pointer; - font-size: 1rem; - display: flex; - align-items: center; - justify-content: center; - transition: var(--transition); -} - -.file-btn:hover { - background: var(--border-color); - transform: scale(1.05); -} - -.file-btn-play:hover { - background: #10b981; - color: white; -} - -.file-btn-loop:hover { - background: var(--primary-color); - color: white; -} - -.file-btn-favorite { - font-size: 1.1rem; -} - -.file-btn-favorite:hover { - background: #fbbf24; - color: white; -} - -.file-btn-favorite.active { - background: #fbbf24; - color: white; -} - -.file-btn-favorite.active:hover { - background: #f59e0b; -} - -.file-btn-delete:hover { - background: var(--danger-color); - color: white; -} - -.logs-container { - background: var(--bg-secondary); - border-radius: var(--border-radius-lg); - padding: 20px; - box-shadow: var(--shadow); -} - -.logs-header { - display: flex; - align-items: center; - gap: 10px; - margin-bottom: 16px; -} - -.logs-header h3 { - font-size: 1.1rem; - font-weight: 600; - color: var(--text-primary); -} - -.clear-btn { - padding: 8px; - min-width: auto; -} - -.logs-content { - background: var(--bg-tertiary); - border-radius: var(--border-radius); - padding: 14px; - max-height: 200px; - overflow-y: auto; - font-family: 'Consolas', 'Monaco', monospace; - font-size: 0.85rem; - line-height: 1.6; + font-family: system-ui, -apple-system, "Segoe UI", "PingFang SC", + "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif; + font-size: 16px; + line-height: 1.6; + background: var(--paper); + color: var(--ink); + min-height: 100dvh; + -webkit-font-smoothing: antialiased; + padding: max(22px, env(safe-area-inset-top)) max(18px, env(safe-area-inset-right)) + max(34px, env(safe-area-inset-bottom)) max(18px, env(safe-area-inset-left)); + transition: background-color 0.25s ease, color 0.25s ease; +} + +/* 正在广播时,页面顶端出现一条朱红横线 */ +body::before { + content: ""; + position: fixed; + inset: 0 0 auto 0; + height: 3px; + background: var(--live); + transform: scaleX(0); + transform-origin: left; + transition: transform 0.28s cubic-bezier(0.2, 0.7, 0.3, 1); + z-index: 10; +} +body.is-live::before { transform: scaleX(1); } + +.shell { max-width: 640px; margin: 0 auto; } + +.sr-only { + position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; + overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; +} + +/* ---------- 页头 ---------- */ + +.top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 26px; +} + +.wordmark { + font-size: 1.85rem; + font-weight: 800; + letter-spacing: 0.1em; + line-height: 1.1; +} + +.top-actions { display: flex; align-items: center; gap: 12px; } + +.live-dot { + width: 8px; height: 8px; border-radius: 50%; + background: var(--line); + transition: background-color 0.25s ease; +} +body.is-live .live-dot { + background: var(--live); + animation: breathe 1.8s ease-in-out infinite; +} +@keyframes breathe { 0%, 100% { opacity: 1; } 50% { opacity: 0.22; } } + +.ghost-btn { + font: inherit; + font-size: 0.8125rem; + color: var(--ink-soft); + background: none; + border: 1px solid var(--line); + border-radius: 999px; + padding: 6px 14px; + cursor: pointer; + transition: color 0.18s ease, border-color 0.18s ease; +} +.ghost-btn:hover { color: var(--ink); border-color: var(--ink-soft); } +.ghost-btn.small { padding: 4px 12px; font-size: 0.75rem; } +.ghost-btn:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; } + +/* ---------- 输入区(唯一有阴影的面) ---------- */ + +.slab { + background: var(--surface); + border: var(--slab-ring); + border-radius: 20px; + box-shadow: var(--slab-shadow); + padding: 4px 4px 0; + transition: background-color 0.25s ease, border-color 0.25s ease; +} +.slab:focus-within { outline: 2px solid var(--ink); outline-offset: 3px; } + +#text { + display: block; + width: 100%; + border: 0; + background: none; + color: inherit; + font: inherit; + font-size: 1.0625rem; + line-height: 1.75; + padding: 20px 18px 6px; + resize: none; + overflow: auto; + max-height: 420px; +} +#text::placeholder { color: var(--ink-soft); opacity: 0.7; } +#text:focus { outline: none; } +#text.nudge { animation: nudge 0.5s ease; } +@keyframes nudge { + 0%, 100% { transform: translateX(0); } + 25% { transform: translateX(-5px); } + 75% { transform: translateX(5px); } +} + +.slab-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px 14px; + flex-wrap: wrap; + padding: 6px 18px 14px; +} + +.meta { + font-size: 0.8125rem; + color: var(--ink-soft); + font-variant-numeric: tabular-nums; +} + +.voice-pick { position: relative; display: inline-flex; align-items: center; min-width: 0; } + +#voice { + appearance: none; + -webkit-appearance: none; + font: inherit; + font-size: 0.875rem; + color: var(--ink); + background: none; + border: 0; + border-bottom: 1px solid var(--line); + border-radius: 0; + padding: 4px 22px 4px 0; + max-width: 52vw; + cursor: pointer; + text-overflow: ellipsis; + transition: border-color 0.18s ease; +} +#voice:hover { border-color: var(--ink-soft); } +#voice:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; } +#voice option { background: var(--surface); color: var(--ink); } + +.voice-pick::after { + content: ""; + position: absolute; + right: 5px; + top: 50%; + width: 6px; height: 6px; + border-right: 1.5px solid var(--ink-soft); + border-bottom: 1.5px solid var(--ink-soft); + transform: translateY(-70%) rotate(45deg); + pointer-events: none; +} + +/* ---------- 循环与间隔 ---------- */ + +.loop-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + margin: 18px 2px 0; +} + +.switch { + display: inline-flex; + align-items: center; + gap: 10px; + font-size: 0.9375rem; + cursor: pointer; + user-select: none; +} +.switch input { position: absolute; opacity: 0; width: 0; height: 0; } + +.switch-track { + position: relative; + flex: none; + width: 38px; height: 22px; + border-radius: 999px; + background: var(--line); + transition: background-color 0.2s ease; +} +.switch-track::after { + content: ""; + position: absolute; + top: 3px; left: 3px; + width: 16px; height: 16px; + border-radius: 50%; + background: var(--surface); + transition: transform 0.2s ease; +} +.switch input:checked + .switch-track { background: var(--ink); } +.switch input:checked + .switch-track::after { transform: translateX(16px); } +.switch input:focus-visible + .switch-track { outline: 2px solid var(--ink); outline-offset: 2px; } + +.interval { display: flex; align-items: center; gap: 12px; } +.interval[hidden] { display: none; } + +#interval { + appearance: none; + -webkit-appearance: none; + width: 118px; + height: 22px; + background: none; + cursor: pointer; +} +#interval::-webkit-slider-runnable-track { height: 2px; background: var(--line); } +#interval::-webkit-slider-thumb { + appearance: none; + -webkit-appearance: none; + margin-top: -7px; + width: 16px; height: 16px; + border-radius: 50%; + background: var(--ink); +} +#interval::-moz-range-track { height: 2px; background: var(--line); } +#interval::-moz-range-thumb { + width: 16px; height: 16px; border: 0; + border-radius: 50%; background: var(--ink); +} +#interval:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; border-radius: 999px; } + +#interval-out { min-width: 4.6em; } + +/* ---------- 广播按钮:界面上唯一一件大事 ---------- */ + +.broadcast { + display: block; + width: 100%; + margin-top: 22px; + font: inherit; + font-size: 1.0625rem; + font-weight: 700; + letter-spacing: 0.06em; + color: var(--paper); + background: var(--ink); + border: 0; + border-radius: 20px; + padding: 20px 24px; + cursor: pointer; + transition: background-color 0.22s ease, color 0.22s ease, transform 0.1s ease; +} +.broadcast:hover { background: var(--ink-hover); } +.broadcast:active { transform: translateY(1px); } +.broadcast:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; } +.broadcast:disabled { cursor: default; opacity: 0.55; } +.broadcast.is-stop { background: var(--live); color: var(--live-ink); } +.broadcast.is-stop:hover { background: var(--live-hover); } + +/* ---------- 抽屉 ---------- */ + +.drawer { margin-top: 30px; border-top: 1px solid var(--line); } + +.drawer-head { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 15px 0; + font: inherit; + color: inherit; + text-align: left; + background: none; + border: 0; + cursor: pointer; +} +.drawer-head:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; } + +.drawer-title { + flex: none; + font-size: 0.9375rem; + font-weight: 600; + white-space: nowrap; +} +.drawer-meta { + margin-right: auto; + min-width: 0; + font-size: 0.8125rem; + color: var(--ink-soft); + font-variant-numeric: tabular-nums; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.caret { + flex: none; + width: 7px; height: 7px; + border-right: 1.5px solid var(--ink-soft); + border-bottom: 1.5px solid var(--ink-soft); + transform: rotate(45deg); + transition: transform 0.2s ease; +} +.drawer-head[aria-expanded="true"] .caret { transform: rotate(225deg); } + +.drawer-body { display: none; padding-bottom: 14px; } +.drawer-body.open { display: block; } + +body.is-live #status-text { color: var(--live); } + +/* ---------- 语音列表 ---------- */ + +.rows { display: flex; flex-direction: column; } + +.row { + display: grid; + gap: 2px; + padding: 14px 0; + border-top: 1px solid var(--line); +} +.row:first-child { border-top: 0; padding-top: 2px; } + +.row-name { + width: 100%; + padding: 2px 0; + font: inherit; + font-size: 0.9375rem; + font-weight: 600; + color: var(--ink); + background: none; + border: 0; + border-radius: 4px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +.row-name:hover { box-shadow: inset 0 -1px 0 var(--line); } +.row-name:focus { outline: none; box-shadow: inset 0 -1px 0 var(--ink); } + +.row-meta { + display: flex; + gap: 12px; + font-size: 0.75rem; + color: var(--ink-soft); + font-variant-numeric: tabular-nums; +} + +.row-actions { + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; + margin-top: 6px; +} + +.act { + font: inherit; + font-size: 0.75rem; + color: var(--ink-soft); + background: none; + border: 1px solid transparent; + border-radius: 999px; + padding: 3px 10px; + cursor: pointer; + transition: color 0.18s ease, border-color 0.18s ease; +} +.act:hover { color: var(--ink); border-color: var(--line); } +.act:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; } +.act[data-act="play"] { color: var(--ink); } +.act.on { color: var(--ink); border-color: var(--ink); } +.act.danger { color: var(--live); border-color: var(--live); } + +.confirm-text { font-size: 0.75rem; color: var(--ink-soft); } + +.empty { padding: 4px 0 10px; font-size: 0.875rem; line-height: 1.7; color: var(--ink-soft); } + +/* ---------- 日志 ---------- */ + +.logs { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 220px; + overflow: auto; + margin-bottom: 12px; + padding-left: 12px; + border-left: 1px solid var(--line); + font-size: 0.8125rem; + color: var(--ink-soft); +} +.log { display: flex; gap: 10px; } +.log-time { flex: none; opacity: 0.6; font-variant-numeric: tabular-nums; } +.log-ok { color: var(--ink); } +.log-err { color: var(--ink); font-weight: 600; } + +/* ---------- 宽屏:抽屉变成右栏常显 ---------- */ + +@media (min-width: 1000px) { + body { padding: 46px 40px 72px; } + + .shell { + max-width: 1000px; + display: grid; + grid-template-columns: minmax(0, 1fr) 340px; + gap: 0 48px; + } + + .top { grid-column: 1 / -1; margin-bottom: 34px; } + .composer { grid-column: 1; grid-row: 2 / span 2; } + + #history { grid-column: 2; grid-row: 2; margin-top: 0; border-top: 0; } + #history .drawer-head { padding-top: 0; } + #status { grid-column: 2; grid-row: 3; margin-top: 24px; } + + .drawer-body { display: block; } + .caret { display: none; } + + #text { min-height: 260px; } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + } } - -.logs-content::-webkit-scrollbar { - width: 6px; -} - -.logs-content::-webkit-scrollbar-track { - background: var(--bg-input); - border-radius: 3px; -} - -.logs-content::-webkit-scrollbar-thumb { - background: var(--border-color); - border-radius: 3px; -} - -.logs-content p { - margin: 4px 0; -} - -.log-time { - color: var(--text-muted); - margin-right: 8px; -} - -.log-info { - color: var(--text-secondary); -} - -.log-success { - color: #10b981; -} - -.log-error { - color: var(--danger-color); -} - -@media (max-width: 480px) { - body { - padding: 12px; - } - - .header h1 { - font-size: 1.4rem; - } - - .theme-toggle { - width: 38px; - height: 38px; - font-size: 1rem; - } - - .main-content { - padding: 18px; - } - - textarea { - height: 120px; - padding: 12px; - } - - .button-group { - flex-direction: column; - } - - .btn { - padding: 12px 16px; - } - - .logs-content { - max-height: 150px; - font-size: 0.8rem; - } - - .file-item { - flex-direction: column; - gap: 10px; - padding: 10px; - } - - .file-info { - width: 100%; - gap: 10px; - } - - .file-icon { - font-size: 1.2rem; - } - - .file-name-input { - font-size: 0.9rem; - } - - .file-meta { - flex-wrap: wrap; - gap: 8px; - font-size: 0.75rem; - } - - .file-actions { - width: 100%; - justify-content: flex-end; - gap: 6px; - } - - .file-btn { - width: 32px; - height: 32px; - font-size: 0.9rem; - } -} - -@media (min-width: 768px) { - .container { - max-width: 700px; - } - - .header h1 { - font-size: 2rem; - } - - .main-content { - padding: 30px; - } - - textarea { - height: 180px; - } -} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index cd25bc6..1a855f0 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,452 +1,485 @@
- - -暂无语音文件
-系统已就绪,等待操作...
-