from flask import Flask, render_template, request, jsonify import subprocess import os import uuid import json import time 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 # 当前播放状态,供 /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): 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(): 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() 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') # 合成音频。新端点会对同一 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)) 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, 'voice': voice, 'is_favorite': False } save_metadata(metadata) _start_playback(filename, loop, interval) return jsonify({'status': 'ok', 'filename': filename}) @app.route('/stop') def stop(): _stop_playback() 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(): 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) or os.path.getsize(filepath) == 0: return jsonify({'status': 'error', 'message': '文件不存在或已损坏'}) _start_playback(filename, loop, interval) 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(): 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': '文件不存在'}) _stop_playback() os.remove(filepath) metadata = get_metadata() if filename in metadata: del metadata[filename] save_metadata(metadata) 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) app.run(host='0.0.0.0', port=5000)