Download app.py from jjf1999/FunASR: direct link, hf CLI and curl.
- Browser
- Download file 16.9 kB
-
https://huggingface.co/spaces/jjf1999/FunASR/resolve/main/app.py
- Command line
-
hf download hf://spaces/jjf1999/FunASR/app.py
-
curl -L -o app.py https://huggingface.co/spaces/jjf1999/FunASR/resolve/main/app.py
16.9 kB
| import os | |
| import re | |
| import shutil | |
| import tempfile | |
| import traceback | |
| import numpy as np | |
| import librosa | |
| import soundfile as sf | |
| from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Depends, Security | |
| from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials | |
| from fastapi.responses import JSONResponse, PlainTextResponse | |
| from funasr import AutoModel | |
| app = FastAPI(title="OpenAI Compatible STT & Unified Report API") | |
| # ================= 1. 安全认证与配置 ================= | |
| API_KEY = os.getenv("API_KEY") | |
| security = HTTPBearer(auto_error=False) | |
| def verify_token(credentials: HTTPAuthorizationCredentials = Security(security)): | |
| """校验请求头中的 Bearer Token""" | |
| if not API_KEY: | |
| return True | |
| if not credentials or credentials.credentials != API_KEY: | |
| raise HTTPException( | |
| status_code=401, | |
| detail="Unauthorized: Invalid or missing API Key." | |
| ) | |
| return True | |
| # 亲昵口语热词配置 | |
| CUSTOM_HOTWORDS = os.getenv( | |
| "HOTWORDS", | |
| "爹地 妈咪 学长 学姐 欧巴 欧尼 宝贝 亲爱的 哥哥 姐姐 弟弟 妹妹 师傅 老公 老婆 爹咪 小路 小路哥哥 因因 岚岚 甜甜 芝芝瓜 小选 小蛇 宝宝 叶瑄 艾因 路辰 罗夏 司岚 星星嘟嘟 牧首 教宗 虎先生 小叶哥哥" | |
| ) | |
| # 口语错别字规则纠错映射 | |
| COLLOQUIAL_CORRECTIONS = { | |
| r"跌地|跌弟|爹底|迭地|嗲地": "爹地", | |
| r"学掌|鞋长|薛长|雪长": "学长", | |
| r"学姐|薛姐|雪姐": "学姐", | |
| r"欧八|偶吧|藕爸|偶霸": "欧巴", | |
| r"欧妮|偶妮|偶泥": "欧尼", | |
| r"马咪|妈米|麻咪": "妈咪", | |
| r"抱抱我|鲍鲍我": "抱抱我" | |
| } | |
| def correct_colloquial_words(text: str) -> str: | |
| """自动修复口语常见同音错别字""" | |
| for pattern, target in COLLOQUIAL_CORRECTIONS.items(): | |
| text = re.sub(pattern, target, text) | |
| return text | |
| def format_cjk_spacing(text: str) -> str: | |
| """在中文字符与英文字母/数字之间自动插入规范空格""" | |
| text = re.sub(r'([\u4e00-\u9fa5])([a-zA-Z0-9])', r'\1 \2', text) | |
| text = re.sub(r'([a-zA-Z0-9])([\u4e00-\u9fa5])', r'\1 \2', text) | |
| return text | |
| # ================= 2. 加载模型 (优先本地离线) ================= | |
| BASE_DIR = os.path.dirname(os.path.abspath(__file__)) | |
| LOCAL_ASR_PATH = os.path.join(BASE_DIR, "models", "SenseVoiceSmall") | |
| LOCAL_EMO_PATH = os.path.join(BASE_DIR, "models", "emotion2vec_plus_seed") | |
| asr_model_path = LOCAL_ASR_PATH if os.path.exists(LOCAL_ASR_PATH) else "FunAudioLLM/SenseVoiceSmall" | |
| emo_model_path = LOCAL_EMO_PATH if os.path.exists(LOCAL_EMO_PATH) else "emotion2vec/emotion2vec_plus_seed" | |
| print(f"[*] Loading ASR from: {asr_model_path}") | |
| asr_model = AutoModel( | |
| model=asr_model_path, | |
| device="cpu", | |
| disable_update=True | |
| ) | |
| print(f"[*] Loading EMO from: {emo_model_path}") | |
| emo_model = AutoModel( | |
| model=emo_model_path, | |
| device="cpu", | |
| disable_update=True | |
| ) | |
| # ================= 3. 音频前处理 (无损原声) ================= | |
| def preprocess_audio(input_path: str, target_sr: int = 16000): | |
| """仅做 16kHz 重采样与防爆音峰值归一化,保留完整声学泛音与弱音""" | |
| y, sr = librosa.load(input_path, sr=target_sr, mono=True) | |
| if len(y) == 0: | |
| return input_path, 0.0 | |
| duration = float(len(y) / sr) | |
| # 峰值归一化至 -0.5 dB | |
| max_val = float(np.max(np.abs(y))) | |
| if max_val > 0: | |
| y = (y / max_val) * 0.95 | |
| processed_path = input_path + "_processed.wav" | |
| sf.write(processed_path, y, sr, subtype="PCM_16") | |
| return processed_path, duration | |
| # ================= 4. 声学通用时序停顿与拉长音检测 ================= | |
| def detect_prosody_marks(audio_path: str, raw_text: str) -> str: | |
| """ | |
| 通用声学韵律标点检测(不再依赖固定词表,全句型通用): | |
| - 任意字词叠音结巴: 我…我 / 这个…这个 / 啊…啊、啊! | |
| - 短促顿音 (0.15s~0.35s): 、 | |
| - 短语气停顿 (0.35s~0.75s): … | |
| - 长时思考/留白 (>=0.75s): …… | |
| - 尾音明显拖长 (>=0.65s): ~ | |
| """ | |
| text = raw_text.strip() | |
| if not text: | |
| return text | |
| # 1. 任意单字/词组叠音卡顿处理 (如 "啊 啊" -> "啊…啊", "这个 这个" -> "这个…这个") | |
| def replace_stutter(match): | |
| w1, sep, w2 = match.group(1), match.group(2), match.group(3) | |
| if w1 == w2: | |
| return f"{w1}…{w2}" | |
| return f"{w1}、{w2}" | |
| text = re.sub(r'([\u4e00-\u9fa5a-zA-Z]{1,2})([,\s,]+)([\u4e00-\u9fa5a-zA-Z]{1,2})', replace_stutter, text) | |
| # 2. 结合音频物理静音间隙(Silence Gaps)动态映射语气停顿深度 | |
| try: | |
| y, sr = librosa.load(audio_path, sr=16000, mono=True) | |
| # 获取有效发音区间 (高于 25dB 视为发音) | |
| intervals = librosa.effects.split(y, top_db=25) | |
| # 统计句中的最大物理停顿 | |
| if len(intervals) > 1: | |
| max_gap = 0.0 | |
| for i in range(len(intervals) - 1): | |
| gap = (intervals[i+1][0] - intervals[i][1]) / sr | |
| if gap > max_gap: | |
| max_gap = gap | |
| if max_gap >= 0.75: | |
| # 长停顿 (>= 0.75s) 映射为 …… | |
| text = re.sub(r'[,,]', '……', text, count=1) | |
| elif max_gap >= 0.35: | |
| # 短停顿 (0.35s ~ 0.75s) 映射为 … | |
| text = re.sub(r'[,,]', '…', text, count=1) | |
| elif 0.15 <= max_gap < 0.35: | |
| # 短促顿音 (0.15s ~ 0.35s) 映射为 、 | |
| text = re.sub(r'[,,]', '、', text, count=1) | |
| # 3. 尾音拉长检测(任意末尾字音节持续发音 >= 0.65s 自动追加 ~) | |
| if len(intervals) > 0: | |
| last_start, last_end = intervals[-1] | |
| last_duration = (last_end - last_start) / sr | |
| if last_duration >= 0.65: | |
| if not text.endswith("~") and not text.endswith("……") and not text.endswith("…"): | |
| text += "~" | |
| except Exception as e: | |
| print(f"[Warn] Prosody detection fallback: {e}") | |
| return text | |
| # ================= 5. 情绪与时序解析 ================= | |
| DEFAULT_LABELS = ["angry", "disgusted", "fearful", "happy", "neutral", "sad", "surprised", "other"] | |
| def get_energy_state(tone: str) -> str: | |
| if tone in ["angry", "surprised"]: | |
| return "high / rising" | |
| elif tone in ["sad"]: | |
| return "low / falling" | |
| elif tone in ["happy", "coquetry"]: | |
| return "cheerful / dynamic" | |
| return "moderate / flat" | |
| def parse_emotion_and_timeline(emo_res, total_duration: float, window_size: float = 5.0): | |
| """提取全局情绪与时序切片(10秒以下不切分)""" | |
| emotion_scores = {} | |
| top_emotion = "neutral" | |
| timeline = [] | |
| if not emo_res or len(emo_res) == 0: | |
| return "neutral", {"neutral": 1.0}, [{ | |
| "start": 0.0, | |
| "end": round(float(total_duration), 2), | |
| "tone": "neutral", | |
| "confidence": 1.0, | |
| "energy_state": "moderate / flat" | |
| }] | |
| item = emo_res[0] | |
| labels = item.get("labels", DEFAULT_LABELS) | |
| scores = item.get("scores", None) | |
| if isinstance(scores, dict): | |
| emotion_scores = {str(k): round(float(v), 4) for k, v in scores.items()} | |
| elif isinstance(scores, (list, np.ndarray)): | |
| score_list = [float(x) for x in scores] | |
| for idx, s in enumerate(score_list): | |
| lbl = labels[idx] if idx < len(labels) else f"label_{idx}" | |
| emotion_scores[str(lbl)] = round(s, 4) | |
| if not emotion_scores: | |
| emotion_scores = {"neutral": 1.0} | |
| top_emotion = max(emotion_scores, key=emotion_scores.get) | |
| # 10 秒及以下不切分,直接返回整段单一切片 | |
| if total_duration <= 10.0: | |
| seg_tone = top_emotion if emotion_scores[top_emotion] >= 0.60 else "neutral" | |
| timeline.append({ | |
| "start": 0.0, | |
| "end": round(float(total_duration), 2), | |
| "tone": seg_tone, | |
| "confidence": emotion_scores[top_emotion], | |
| "energy_state": get_energy_state(seg_tone) | |
| }) | |
| return top_emotion, emotion_scores, timeline | |
| # 10 秒以上时间轴切片 | |
| raw_feats = item.get("feats") if "feats" in item else None | |
| if raw_feats is not None: | |
| try: | |
| feats = np.array(raw_feats, dtype=np.float32) | |
| if feats.ndim == 1: | |
| feats = np.expand_dims(feats, axis=0) | |
| total_frames = feats.shape[0] | |
| if total_frames > 0: | |
| frames_per_window = max(1, int(total_frames * (window_size / max(total_duration, 0.1)))) | |
| num_windows = int(np.ceil(total_frames / frames_per_window)) | |
| for i in range(num_windows): | |
| start_f = i * frames_per_window | |
| end_f = min(total_frames, (i + 1) * frames_per_window) | |
| chunk = feats[start_f:end_f] | |
| mean_vec = np.mean(chunk, axis=0) if chunk.ndim > 1 else chunk | |
| start_t = round(float(i * window_size), 2) | |
| end_t = round(float(min(total_duration, (i + 1) * window_size)), 2) | |
| if len(mean_vec) == len(labels): | |
| t_idx = int(np.argmax(mean_vec)) | |
| t_label = labels[t_idx] | |
| conf = float(mean_vec[t_idx]) | |
| else: | |
| t_label = top_emotion | |
| conf = float(emotion_scores.get(top_emotion, 0.8)) | |
| # 局部时序置信度不足 0.60 同样保底降级为 neutral | |
| final_seg_tone = str(t_label) if conf >= 0.60 else "neutral" | |
| timeline.append({ | |
| "start": start_t, | |
| "end": end_t, | |
| "tone": final_seg_tone, | |
| "confidence": round(float(conf), 4), | |
| "energy_state": get_energy_state(final_seg_tone) | |
| }) | |
| except Exception as e: | |
| print(f"[Warn] Timeline parse failed: {e}") | |
| if not timeline: | |
| timeline.append({ | |
| "start": 0.0, | |
| "end": round(float(total_duration), 2), | |
| "tone": top_emotion if emotion_scores[top_emotion] >= 0.60 else "neutral", | |
| "confidence": emotion_scores[top_emotion], | |
| "energy_state": get_energy_state(top_emotion if emotion_scores[top_emotion] >= 0.60 else "neutral") | |
| }) | |
| return top_emotion, emotion_scores, timeline | |
| def evaluate_tone_and_emotion(text: str, emotion_scores: dict, raw_top_emotion: str): | |
| """ | |
| 结合声学与文本语义进行多模态裁决: | |
| 1. 撒娇/软萌判定门槛为 0.45 | |
| 2. 常规声学情绪(happy/sad/angry/等)必须 >= 0.60,否则保底为 neutral | |
| """ | |
| top_score = float(emotion_scores.get(raw_top_emotion, 0.0)) | |
| happy_score = float(emotion_scores.get("happy", 0.0)) | |
| surprised_score = float(emotion_scores.get("surprised", 0.0)) | |
| has_soft_words = bool(re.search(r"[嘛啦呀吧啵呗嘤~~…]", text)) | |
| # 1. 撒娇判定 | |
| if (happy_score + surprised_score >= 0.45) and has_soft_words: | |
| return "coquetry", max(happy_score, top_score) | |
| # 2. 常规声学情绪阈值校验 | |
| if top_score < 0.60: | |
| return "neutral", top_score | |
| # 3. 满足置信度要求,正式采纳该情绪 | |
| return raw_top_emotion, top_score | |
| # ================= 6. 整合文本报告 ================= | |
| EMOTION_ZH_MAP = { | |
| "neutral": "平静/中性", | |
| "happy": "开心/愉悦", | |
| "sad": "低落/悲伤", | |
| "angry": "愤怒/生气", | |
| "surprised": "惊讶/上扬", | |
| "fearful": "害怕/紧张", | |
| "disgusted": "厌恶/反感", | |
| "coquetry": "撒娇/软萌" | |
| } | |
| def format_unified_text_report(clean_text: str, final_emotion: str, confidence: float, duration: float, emotion_scores: dict, prosody_timeline: list) -> str: | |
| """将所有分析维度拼接为一段结构化的纯文本""" | |
| emo_desc = EMOTION_ZH_MAP.get(final_emotion, final_emotion) | |
| sorted_scores = sorted(emotion_scores.items(), key=lambda x: x[1], reverse=True) | |
| scores_str = " | ".join([f"{k}: {round(v * 100, 2)}%" for k, v in sorted_scores if v > 0.01]) | |
| timeline_items = [] | |
| for item in prosody_timeline: | |
| timeline_items.append(f"{item['start']}s-{item['end']}s: {item['tone']} ({item['energy_state']})") | |
| timeline_str = " -> ".join(timeline_items) | |
| lines = [ | |
| f"[语音识别文本]:{clean_text}", | |
| f"[主导语气]:{final_emotion} ({emo_desc})", | |
| f"[综合置信度]:{round(confidence, 4)}", | |
| f"[音频时长]:{round(duration, 2)}秒", | |
| f"[声学情绪分布]:{scores_str}", | |
| f"[语调起伏走势]:{timeline_str}", | |
| f"注:识别文本有概率因读音近似出现错误" | |
| ] | |
| return "\n".join(lines) | |
| # ================= 7. API 路由端点 ================= | |
| async def root(): | |
| return { | |
| "status": "online", | |
| "service": "SenseVoice + Emotion2Vec+ Unified Report API", | |
| "docs": "/docs", | |
| "endpoint": "/v1/audio/transcriptions" | |
| } | |
| async def transcribe_audio( | |
| file: UploadFile = File(...), | |
| model_name: str = Form(default="whisper-1", alias="model"), | |
| language: str = Form(default="auto"), | |
| response_format: str = Form(default="json"), | |
| authenticated: bool = Depends(verify_token) | |
| ): | |
| suffix = os.path.splitext(file.filename)[-1] or ".wav" | |
| with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: | |
| shutil.copyfileobj(file.file, tmp) | |
| raw_path = tmp.name | |
| processed_path = None | |
| try: | |
| # 1. 音频前处理(无损原声) | |
| processed_path, duration = preprocess_audio(raw_path) | |
| # 2. SenseVoice 识别文本 | |
| asr_lang = language if language in ["zh", "en", "yue", "ja", "ko", "auto"] else "auto" | |
| asr_res = asr_model.generate( | |
| input=processed_path, | |
| language=asr_lang, | |
| use_itn=True, | |
| hotword=CUSTOM_HOTWORDS | |
| ) | |
| raw_text = asr_res[0]["text"] if asr_res and len(asr_res) > 0 else "" | |
| clean_text = re.sub(r"<\|.*?\|>", "", raw_text).strip() | |
| # 3. 声学通用时序停顿与拉长音注入 | |
| clean_text = detect_prosody_marks(processed_path, clean_text) | |
| # 4. 中文口语纠错与中英空格排版 | |
| clean_text = correct_colloquial_words(clean_text) | |
| clean_text = format_cjk_spacing(clean_text) | |
| # 5. Emotion2Vec+ 提取情绪与概率 | |
| emo_res = emo_model.generate( | |
| input=processed_path, | |
| granularity="utterance" | |
| ) | |
| raw_top_emotion, emotion_scores, prosody_timeline = parse_emotion_and_timeline( | |
| emo_res, | |
| duration, | |
| window_size=5.0 | |
| ) | |
| # 6. 多模态综合语气裁决(阈值 >= 0.60) | |
| final_emotion, confidence = evaluate_tone_and_emotion( | |
| clean_text, | |
| emotion_scores, | |
| raw_top_emotion | |
| ) | |
| # 7. 生成整合文本报告 | |
| unified_report_text = format_unified_text_report( | |
| clean_text=clean_text, | |
| final_emotion=final_emotion, | |
| confidence=confidence, | |
| duration=duration, | |
| emotion_scores=emotion_scores, | |
| prosody_timeline=prosody_timeline | |
| ) | |
| if response_format == "text": | |
| return PlainTextResponse(unified_report_text) | |
| elif response_format == "verbose_json": | |
| return JSONResponse({ | |
| "task": "transcribe", | |
| "language": str(asr_lang), | |
| "duration": round(float(duration), 2), | |
| "text": unified_report_text, | |
| "raw_text": clean_text, | |
| "emotion": str(final_emotion), | |
| "confidence": round(float(confidence), 4), | |
| "emotion_scores": emotion_scores, | |
| "prosody_timeline": prosody_timeline, | |
| "segments": [] | |
| }) | |
| else: | |
| return JSONResponse({ | |
| "text": unified_report_text, | |
| "raw_text": clean_text, | |
| "duration": round(float(duration), 2), | |
| "emotion": str(final_emotion), | |
| "confidence": round(float(confidence), 4), | |
| "emotion_scores": emotion_scores, | |
| "prosody_timeline": prosody_timeline | |
| }) | |
| except Exception as e: | |
| traceback.print_exc() | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| finally: | |
| if os.path.exists(raw_path): | |
| os.remove(raw_path) | |
| if processed_path and os.path.exists(processed_path): | |
| os.remove(processed_path) |