Files

175 lines
7.8 KiB
Python

"""Bounded, in-memory recording transcription; separate from text chat credentials."""
import base64
import json
import math
import hashlib
import os
from pathlib import Path
import shutil
import subprocess
import threading
import time
from urllib.parse import urlsplit
import requests
from flask import current_app
from .config import get_setting
MAX_AUDIO_BYTES = 5 * 1024 * 1024
MAX_DURATION_SECONDS = 60
UNAVAILABLE = '语音输入暂不可用,请使用文字输入。'
class SpeechFailure(Exception):
def __init__(self, message, status=502):
self.message, self.status = message, status
def ffmpeg_binary():
configured = get_setting('FFMPEG_BIN')
return str(configured) if configured and Path(configured).is_file() else shutil.which('ffmpeg')
def speech_enabled():
provider = get_setting('ASR_PROVIDER')
if provider == 'baidu':
return bool(get_setting('BAIDU_ASR_API_KEY') and get_setting('BAIDU_ASR_SECRET_KEY') and ffmpeg_binary())
if provider != 'dashscope':
return False
try:
url = urlsplit(get_setting('ASR_URL'))
except ValueError:
return False
return bool(get_setting('ASR_API_KEY') and get_setting('ASR_MODEL')
and url.scheme == 'https' and url.hostname and not url.username and not url.password)
def post_json(url, **options):
response = requests.post(url, timeout=(5, 30), stream=True, allow_redirects=False, **options)
try:
if response.status_code != 200:
raise SpeechFailure('语音转文字失败,请稍后重试。')
data = bytearray()
for chunk in response.iter_content(4096):
data.extend(chunk)
if len(data) > 64 * 1024:
raise SpeechFailure('语音识别结果无效,请重试。')
return json.loads(data)
finally:
response.close()
def baidu_token():
key, secret = get_setting('BAIDU_ASR_API_KEY'), get_setting('BAIDU_ASR_SECRET_KEY')
identity = hashlib.sha256((key + '\0' + secret).encode()).hexdigest()
lock = current_app.extensions.setdefault('speech_token_lock', threading.Lock())
with lock:
cached = current_app.extensions.get('speech_token')
if cached and cached['identity'] == identity and cached['expires'] > time.time():
return cached['token']
data = post_json('https://aip.baidubce.com/oauth/2.0/token', data={
'grant_type':'client_credentials', 'client_id':key, 'client_secret':secret})
token = data.get('access_token')
if not isinstance(token, str) or not token:
raise SpeechFailure(UNAVAILABLE, 503)
current_app.extensions['speech_token'] = {'identity':identity, 'token':token,
'expires':time.time() + max(0, min(float(data.get('expires_in', 0)), 2592000) - 60)}
return token
def to_pcm(content):
"""Decode browser WebM/MP4 in pipes to mono 16 kHz, without temporary audio files."""
binary = ffmpeg_binary()
if not binary:
raise SpeechFailure(UNAVAILABLE, 503)
result = subprocess.run([binary, '-hide_banner', '-loglevel', 'error', '-protocol_whitelist', 'pipe',
'-i', 'pipe:0', '-map', '0:a:0', '-vn', '-sn', '-dn', '-ac', '1', '-ar', '16000',
'-t', '61', '-f', 's16le', 'pipe:1'], input=content, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
timeout=10, creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0)
if result.returncode != 0 or len(result.stdout) < 12800:
raise SpeechFailure('录音无法识别,请重新录制。', 422)
if len(result.stdout) > 16000 * 2 * MAX_DURATION_SECONDS:
raise SpeechFailure('录音超过 60 秒,请缩短后重试。', 400)
return result.stdout
def baidu_transcribe(content, language):
pcm = to_pcm(content)
result = post_json('https://vop.baidu.com/server_api', json={'format':'pcm', 'rate':16000, 'channel':1,
'cuid':'baiyue-logistics-chat', 'token':baidu_token(), 'dev_pid':1737 if language == 'en' else 1537,
'speech':base64.b64encode(pcm).decode('ascii'), 'len':len(pcm)})
code = result.get('err_no')
if code == 3301:
raise SpeechFailure('没有识别到文字,请靠近麦克风重试。', 422)
if code != 0:
current_app.logger.warning('Baidu ASR request failed: code=%s', code)
raise SpeechFailure(UNAVAILABLE if code == 3302 else '语音转文字失败,请稍后重试。', 503 if code == 3302 else 502)
parts = result.get('result')
if not isinstance(parts, list) or any(not isinstance(part, str) for part in parts):
raise SpeechFailure('语音识别结果无效,请重试。')
return ''.join(parts)
def audio_format(content, mime):
"""Check container signatures, never trust the uploaded filename."""
mime = mime.split(';', 1)[0].lower()
if mime == 'audio/webm' and content.startswith(b'\x1aE\xdf\xa3'):
return 'audio/webm'
if mime in ('audio/mp4', 'audio/x-m4a') and content[4:8] == b'ftyp':
return 'audio/mp4'
if mime in ('audio/ogg', 'audio/opus') and content.startswith(b'OggS'):
return 'audio/ogg'
if mime in ('audio/wav', 'audio/x-wav') and content[:4] == b'RIFF' and content[8:12] == b'WAVE':
return 'audio/wav'
return None
def transcribe_recording(upload, duration, language):
if not speech_enabled():
return {'ok': False, 'error': UNAVAILABLE}, 503
if not upload:
return {'ok': False, 'error': '没有收到录音,请重新按住说话。'}, 400
try:
duration = float(duration)
except (TypeError, ValueError):
duration = 0
if not math.isfinite(duration) or not 0.4 <= duration <= MAX_DURATION_SECONDS + 1:
return {'ok': False, 'error': '录音时长无效,请录制 1 至 60 秒语音。'}, 400
content = upload.read(MAX_AUDIO_BYTES + 1)
if len(content) > MAX_AUDIO_BYTES:
return {'ok': False, 'error': '录音文件过大,请缩短后重试。'}, 413
mime = audio_format(content, upload.mimetype)
if len(content) < 16 or not mime:
return {'ok': False, 'error': '录音格式不受支持,请使用文字输入或更换浏览器。'}, 415
language = 'en' if language == 'en' else 'zh'
try:
if get_setting('ASR_PROVIDER') == 'baidu':
text = baidu_transcribe(content, language)
else:
body = {'model': get_setting('ASR_MODEL'), 'stream': False,
'messages': [{'role': 'user', 'content': [{'type': 'input_audio', 'input_audio': {
'data': 'data:' + mime + ';base64,' + base64.b64encode(content).decode('ascii')}}]}],
'asr_options': {'language': language, 'enable_itn': True}}
result = post_json(get_setting('ASR_URL'), json=body,
headers={'Authorization': 'Bearer ' + get_setting('ASR_API_KEY')})
text = result['choices'][0]['message']['content']
if not isinstance(text, str) or not text.strip():
return {'ok': False, 'error': '没有识别到文字,请靠近麦克风重试。'}, 422
text = text.strip()
if len(text) > 2000:
return {'ok': False, 'error': '识别文字过长,请缩短录音后重试。'}, 422
return {'ok': True, 'text': text}, 200
except requests.Timeout:
return {'ok': False, 'error': '语音转文字超时,请重试。'}, 504
except SpeechFailure as error:
return {'ok': False, 'error': error.message}, error.status
except subprocess.TimeoutExpired:
return {'ok': False, 'error': '录音处理超时,请缩短后重试。'}, 504
except OSError:
return {'ok': False, 'error': UNAVAILABLE}, 503
except (requests.RequestException, ValueError, KeyError, IndexError, TypeError):
return {'ok': False, 'error': '语音转文字失败,请稍后重试。'}, 502