feat: complete local version to overwrite remote

This commit is contained in:
DelLevin-Home
2026-06-16 03:30:57 +08:00
parent 1735c19f48
commit 3c78293f4d
129 changed files with 22814 additions and 0 deletions

View File

@@ -0,0 +1,338 @@
# -*- coding: utf-8 -*-
"""
STT 语音识别蓝图
使用 faster-whisper 实现音频转文字
"""
import os
import json
import uuid
import subprocess
import tempfile
import threading
import time
from datetime import timedelta
from flask import Blueprint, render_template, request, jsonify
bp = Blueprint('stt', __name__, url_prefix='/stt')
# HuggingFace 镜像
os.environ['HF_ENDPOINT'] = 'https://hf-mirror.com'
# OpenCC 繁体转简体
try:
from opencc import OpenCC
_cc_t2s = OpenCC('t2s')
except Exception:
_cc_t2s = None
try:
from config import BASE_DIR
except ImportError:
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CONFIG_PATH = os.path.join(BASE_DIR, 'config', 'stt_config.json')
# 模型缓存
_model_cache = {}
_task_queue = []
_task_progress = {}
_task_results = {}
_worker_started = False
_worker_lock = threading.Lock()
LANGUAGES = {
'auto': '自动检测',
'zh': '中文', 'en': '英语', 'ja': '日语', 'ko': '韩语',
'fr': '法语', 'de': '德语', 'es': '西班牙语', 'ru': '俄语',
'th': '泰语', 'it': '意大利语', 'pt': '葡萄牙语', 'vi': '越南语',
'ar': '阿拉伯语', 'tr': '土耳其语',
}
def _load_config():
cfg = {'model_dir': ''}
if os.path.exists(CONFIG_PATH):
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
cfg.update(json.load(f))
return cfg
def _save_config(cfg):
with open(CONFIG_PATH, 'w', encoding='utf-8') as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
def _scan_models(directory):
"""扫描模型目录,提取已下载的模型名称"""
models = []
if not directory or not os.path.isdir(directory):
return models
prefix = 'models--Systran--faster-whisper-'
for name in os.listdir(directory):
full = os.path.join(directory, name)
if os.path.isdir(full) and name.startswith(prefix):
model_name = name[len(prefix):]
if model_name:
models.append(model_name)
return sorted(models)
@bp.route('/')
def page():
cfg = _load_config()
return render_template('stt.html', models=_scan_models(cfg['model_dir']), languages=LANGUAGES)
@bp.route('/config', methods=['GET'])
def get_config():
cfg = _load_config()
cfg['models'] = _scan_models(cfg['model_dir'])
return jsonify(cfg)
@bp.route('/config', methods=['POST'])
def save_config():
data = request.get_json()
cfg = _load_config()
if 'model_dir' in data:
cfg['model_dir'] = data['model_dir'].strip()
_save_config(cfg)
return jsonify({'success': True})
@bp.route('/models')
def list_models():
cfg = _load_config()
return jsonify({'models': _scan_models(cfg['model_dir'])})
@bp.route('/cuda-check')
def cuda_check():
"""检查 CUDA 是否可用ctranslate2 优先PyTorch 兜底)"""
# 方法1: ctranslate2faster-whisper 的实际后端)
try:
import ctranslate2
count = ctranslate2.get_cuda_device_count()
if count > 0:
name = 'CUDA Device'
try:
name = ctranslate2.get_cuda_device_name(0) or name
except Exception:
pass
return jsonify({'cuda': True, 'device_count': count, 'name': name})
except Exception:
pass
# 方法2: PyTorch
try:
import torch
if torch.cuda.is_available():
return jsonify({'cuda': True, 'device_count': torch.cuda.device_count(),
'name': torch.cuda.get_device_name(0)})
except Exception:
pass
return jsonify({'cuda': False})
def _ms_to_srt_time(ms):
td = timedelta(milliseconds=ms)
h, rem = divmod(td.seconds, 3600)
m, s = divmod(rem, 60)
ms_part = td.microseconds // 1000
return f'{h:02d}:{m:02d}:{s:02d},{ms_part:03d}'
def _convert_to_wav(input_path):
"""用 FFmpeg 转为 16kHz 单声道 WAV返回 wav 路径或 None"""
wav_path = os.path.join(tempfile.gettempdir(), uuid.uuid4().hex + '.wav')
cmd = ['ffmpeg', '-y', '-i', input_path, '-ar', '16000', '-ac', '1', wav_path]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300,
creationflags=0x08000000 if os.name == 'nt' else 0)
if result.returncode == 0 and os.path.exists(wav_path):
return wav_path
except Exception:
pass
return None
def _get_model(model_name, device='cpu'):
"""获取或加载模型CUDA 失败自动回退 CPU"""
cfg = _load_config()
model_dir = cfg['model_dir']
cache_key = f'{model_name}_{device}_{model_dir}'
if cache_key not in _model_cache:
from faster_whisper import WhisperModel
try:
_model_cache[cache_key] = WhisperModel(
model_name, device=device, download_root=model_dir
)
except Exception as e:
if device == 'cuda':
# CUDA 加载失败,回退 CPU
cpu_key = f'{model_name}_cpu_{model_dir}'
if cpu_key not in _model_cache:
_model_cache[cpu_key] = WhisperModel(
model_name, device='cpu', download_root=model_dir
)
return _model_cache[cpu_key], f'CUDA 加载失败({e}),已回退到 CPU'
raise
return _model_cache[cache_key], None
def _process_task(task):
"""处理单个转录任务"""
task_id = task['task_id']
fmt = task.get('format', 'text')
device = task.get('device', 'cpu')
try:
_task_progress[task_id] = {'percent': 0, 'status': 'loading', 'format': fmt}
model, warn = _get_model(task['model'], device)
if warn:
_task_progress[task_id] = {'percent': 0, 'status': 'transcribing', 'format': fmt, 'warning': warn}
else:
_task_progress[task_id] = {'percent': 0, 'status': 'transcribing', 'format': fmt}
lang = task['language'] if task['language'] != 'auto' else None
segments, info = model.transcribe(
task['wav_path'],
beam_size=5, best_of=5,
vad_filter=True,
language=lang,
)
total_duration = max(info.duration, 0.01)
results = []
for seg in segments:
_task_progress[task_id] = {
'percent': round(seg.end / total_duration, 2),
'status': 'transcribing',
'format': fmt,
}
text = seg.text.strip()
if not text or len(text) <= 1:
continue
if _cc_t2s:
text = _cc_t2s.convert(text)
start_ms = int(seg.start * 1000)
end_ms = int(seg.end * 1000)
results.append({
'start': start_ms,
'end': end_ms,
'start_time': _ms_to_srt_time(start_ms),
'end_time': _ms_to_srt_time(end_ms),
'text': text,
})
_task_results[task_id] = results
_task_progress[task_id] = {'percent': 1, 'status': 'done', 'format': fmt}
except Exception as e:
_task_progress[task_id] = {'percent': 0, 'status': 'error', 'error': str(e), 'format': fmt}
finally:
# 清理临时文件
try:
if os.path.exists(task['wav_path']):
os.remove(task['wav_path'])
except Exception:
pass
def _worker():
"""后台 worker 线程"""
while True:
if not _task_queue:
time.sleep(1)
continue
task = _task_queue.pop(0)
_process_task(task)
def _ensure_worker():
global _worker_started
if not _worker_started:
with _worker_lock:
if not _worker_started:
t = threading.Thread(target=_worker, daemon=True)
t.start()
_worker_started = True
@bp.route('/transcribe', methods=['POST'])
def transcribe():
if 'audio' not in request.files:
return jsonify({'success': False, 'error': '请上传音频文件'}), 400
audio_file = request.files['audio']
if not audio_file.filename:
return jsonify({'success': False, 'error': '未选择文件'}), 400
model_name = request.form.get('model', 'base')
language = request.form.get('language', 'auto')
device = request.form.get('device', 'cpu')
output_format = request.form.get('format', 'text')
if model_name not in _scan_models(_load_config()['model_dir']):
return jsonify({'success': False, 'error': f'模型 {model_name} 不存在,请先下载到 stt_models 目录'}), 400
# 保存原始文件
ext = os.path.splitext(audio_file.filename)[1].lower()
original_path = os.path.join(tempfile.gettempdir(), uuid.uuid4().hex + ext)
audio_file.save(original_path)
# 转 WAV
wav_path = _convert_to_wav(original_path)
try:
os.remove(original_path)
except Exception:
pass
if not wav_path:
return jsonify({'success': False, 'error': '音频转换失败,请确保 FFmpeg 已安装'}), 500
# 创建任务
task_id = uuid.uuid4().hex
_task_progress[task_id] = {'percent': 0, 'status': 'queued', 'format': output_format}
_task_queue.append({
'task_id': task_id,
'wav_path': wav_path,
'model': model_name,
'language': language,
'device': device,
'format': output_format,
})
_ensure_worker()
return jsonify({'success': True, 'task_id': task_id})
@bp.route('/status/<task_id>')
def task_status(task_id):
progress = _task_progress.get(task_id)
if not progress:
return jsonify({'success': False, 'error': '任务不存在'}), 404
resp = {'success': True, 'progress': progress}
if progress['status'] == 'done':
results = _task_results.get(task_id, [])
fmt = progress.get('format', 'text')
if fmt == 'srt':
lines = []
for i, r in enumerate(results):
lines.append(f"{i+1}\n{r['start_time']} --> {r['end_time']}\n{r['text']}\n")
resp['result'] = '\n'.join(lines)
elif fmt == 'json':
resp['result'] = results
else:
resp['result'] = '\n'.join(r['text'] for r in results)
# 清理
_task_progress.pop(task_id, None)
_task_results.pop(task_id, None)
return jsonify(resp)