From af259fa94642d2635b5f9eda1b7f256d31c77495 Mon Sep 17 00:00:00 2001 From: Cx330 <1487537121@qq.com> Date: Wed, 27 May 2026 19:09:51 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B5=8B=E8=AF=95=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 317 +++++++++++++++++++++ cache/metadata.json | 7 + player.sh | 10 + static/style.css | 652 +++++++++++++++++++++++++++++++++++++++++++ templates/index.html | 452 ++++++++++++++++++++++++++++++ 5 files changed, 1438 insertions(+) create mode 100644 app.py create mode 100644 cache/metadata.json create mode 100644 player.sh create mode 100644 static/style.css create mode 100644 templates/index.html diff --git a/app.py b/app.py new file mode 100644 index 0000000..258cd32 --- /dev/null +++ b/app.py @@ -0,0 +1,317 @@ +from flask import Flask, render_template, request, jsonify +import subprocess +import os +import uuid +import json + +app = Flask(__name__) + +CACHE_DIR = "cache" +MAX_CACHE_FILES = 10 +METADATA_FILE = os.path.join(CACHE_DIR, 'metadata.json') + +voices = { + "晓晓(温暖女声)": "zh-CN-XiaoxiaoNeural", + "晓伊(活泼女声)": "zh-CN-XiaoyiNeural", + "云健(激情男声)": "zh-CN-YunjianNeural", + "云希(阳光男声)": "zh-CN-YunxiNeural", + "云夏(可爱男声)": "zh-CN-YunxiaNeural", + "云扬(新闻男声)": "zh-CN-YunyangNeural", + + "辽宁-小贝(方言)": "zh-CN-liaoning-XiaobeiNeural", + "陕西-小妮(方言)": "zh-CN-shaanxi-XiaoniNeural", + + "香港-晓佳": "zh-HK-HiuGaaiNeural", + "香港-晓曼": "zh-HK-HiuMaanNeural", + "香港-云龙": "zh-HK-WanLungNeural", + + "台湾-晓陈": "zh-TW-HsiaoChenNeural", + "台湾-晓雨": "zh-TW-HsiaoYuNeural", + "台湾-云哲": "zh-TW-YunJheNeural" +} + +current_process = None + + +def get_metadata(): + if not os.path.exists(METADATA_FILE): + return {} + with open(METADATA_FILE, 'r', encoding='utf-8') as f: + return json.load(f) + + +def save_metadata(metadata): + with open(METADATA_FILE, 'w', encoding='utf-8') as f: + json.dump(metadata, f, ensure_ascii=False, indent=2) + + +def clean_cache(): + if not os.path.exists(CACHE_DIR): + os.makedirs(CACHE_DIR) + return + + files = [] + metadata = get_metadata() + for f in os.listdir(CACHE_DIR): + filepath = os.path.join(CACHE_DIR, f) + if os.path.isfile(filepath) and f.endswith('.mp3'): + files.append({ + 'filepath': filepath, + 'filename': f, + 'created_at': os.path.getctime(filepath), + 'is_favorite': metadata.get(f, {}).get('is_favorite', False) + }) + + total_files = len(files) + if total_files >= MAX_CACHE_FILES: + files.sort(key=lambda x: x['created_at']) + + files_to_delete = [] + non_fav_files = [f for f in files if not f['is_favorite']] + + num_files_to_delete = total_files - MAX_CACHE_FILES + 1 + + if len(non_fav_files) >= num_files_to_delete: + files_to_delete = non_fav_files[:num_files_to_delete] + elif len(non_fav_files) > 0: + files_to_delete = non_fav_files + + for file_info in files_to_delete: + filename = file_info['filename'] + if filename in metadata: + del metadata[filename] + os.remove(file_info['filepath']) + + if files_to_delete: + save_metadata(metadata) + + +@app.route('/') +def index(): + return render_template('index.html', voices=voices) + + +@app.route('/speak', methods=['POST']) +def speak(): + global current_process + + text = request.form.get('text') + voice = request.form.get('voice') + loop = request.form.get('loop') == 'true' + interval = int(request.form.get('interval', 5)) + + if not text: + return jsonify({'status': 'error', 'message': '请输入广播内容'}) + + clean_cache() + + 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 + ]) + + metadata = get_metadata() + display_name = text[:30] + '...' if len(text) > 30 else text + metadata[filename] = { + 'display_name': display_name, + 'text': text, + 'voice': voice, + 'is_favorite': False + } + 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 + ]) + + 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']) + + return jsonify({'status': 'stopped'}) + + +@app.route('/files') +def list_files(): + metadata = get_metadata() + files = [] + + for f in os.listdir(CACHE_DIR): + if f.endswith('.mp3'): + filepath = os.path.join(CACHE_DIR, f) + if os.path.isfile(filepath): + info = metadata.get(f, {'display_name': f, 'text': '', 'voice': ''}) + files.append({ + 'filename': f, + 'display_name': info['display_name'], + 'text': info.get('text', ''), + 'voice': info.get('voice', ''), + 'is_favorite': info.get('is_favorite', False), + 'created_at': os.path.getctime(filepath) + }) + + files.sort(key=lambda x: (not x['is_favorite'], -x['created_at'])) + return jsonify({'files': files}) + + +@app.route('/play-file', methods=['POST']) +def play_file(): + global current_process + + filename = request.form.get('filename') + loop = request.form.get('loop') == 'true' + interval = int(request.form.get('interval', 5)) + + if not filename: + return jsonify({'status': 'error', 'message': '请选择文件'}) + + filepath = os.path.join(CACHE_DIR, filename) + + if not os.path.exists(filepath): + 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 + ]) + + metadata = get_metadata() + display_name = metadata.get(filename, {}).get('display_name', filename) + + return jsonify({'status': 'ok', 'message': f'正在播放: {display_name}'}) + + +@app.route('/rename-file', methods=['POST']) +def rename_file(): + filename = request.form.get('filename') + new_name = request.form.get('new_name') + + if not filename or not new_name: + return jsonify({'status': 'error', 'message': '参数错误'}) + + metadata = get_metadata() + + if filename not in metadata: + return jsonify({'status': 'error', 'message': '文件不存在'}) + + metadata[filename]['display_name'] = new_name + save_metadata(metadata) + + return jsonify({'status': 'ok', 'message': '重命名成功'}) + + +@app.route('/toggle-favorite', methods=['POST']) +def toggle_favorite(): + filename = request.form.get('filename') + + if not filename: + return jsonify({'status': 'error', 'message': '参数错误'}) + + metadata = get_metadata() + + if filename not in metadata: + return jsonify({'status': 'error', 'message': '文件不存在'}) + + metadata[filename]['is_favorite'] = not metadata[filename].get('is_favorite', False) + save_metadata(metadata) + + return jsonify({ + 'status': 'ok', + 'is_favorite': metadata[filename]['is_favorite'], + 'message': '收藏成功' if metadata[filename]['is_favorite'] else '取消收藏成功' + }) + + +@app.route('/delete-file', methods=['POST']) +def delete_file(): + global current_process + + filename = request.form.get('filename') + + if not filename: + return jsonify({'status': 'error', 'message': '请选择文件'}) + + filepath = os.path.join(CACHE_DIR, filename) + + 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']) + + os.remove(filepath) + + metadata = get_metadata() + if filename in metadata: + del metadata[filename] + save_metadata(metadata) + + return jsonify({'status': 'ok', 'message': '删除成功'}) + + +if __name__ == '__main__': + if not os.path.exists(CACHE_DIR): + os.makedirs(CACHE_DIR) + app.run(host='0.0.0.0', port=5000) \ No newline at end of file diff --git a/cache/metadata.json b/cache/metadata.json new file mode 100644 index 0000000..1c7d745 --- /dev/null +++ b/cache/metadata.json @@ -0,0 +1,7 @@ +{ + "13dd99c6-415a-4ac0-9cec-ed2246224a81.mp3": { + "display_name": "12345", + "text": "12345", + "voice": "晓晓(温暖女声)" + } +} \ No newline at end of file diff --git a/player.sh b/player.sh new file mode 100644 index 0000000..3553f24 --- /dev/null +++ b/player.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +FILE=$1 +INTERVAL=${2:-5} + +while true +do + mpg123 -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 new file mode 100644 index 0000000..707cefc --- /dev/null +++ b/static/style.css @@ -0,0 +1,652 @@ +: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; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +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; +} + +.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; + } +} + +@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 new file mode 100644 index 0000000..cd25bc6 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,452 @@ + + +
+ + +暂无语音文件
+系统已就绪,等待操作...
+