354 lines
11 KiB
Python
354 lines
11 KiB
Python
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) |