Update app.py
Browse files
app.py
CHANGED
|
@@ -2,6 +2,7 @@ import os
|
|
| 2 |
import re
|
| 3 |
import shutil
|
| 4 |
import tempfile
|
|
|
|
| 5 |
import numpy as np
|
| 6 |
import librosa
|
| 7 |
import soundfile as sf
|
|
@@ -28,7 +29,7 @@ def verify_token(credentials: HTTPAuthorizationCredentials = Security(security))
|
|
| 28 |
)
|
| 29 |
return True
|
| 30 |
|
| 31 |
-
# 亲昵口语热词配置
|
| 32 |
CUSTOM_HOTWORDS = os.getenv(
|
| 33 |
"HOTWORDS",
|
| 34 |
"爹地 妈咪 学长 学姐 欧巴 欧尼 宝贝 亲爱的 哥哥 姐姐 弟弟 妹妹 师傅 老公 老婆 爹咪 小路 小路哥哥 因因 岚岚 甜甜 芝芝瓜 小选 小蛇 宝宝 叶瑄 艾因 路辰 罗夏 司岚 星星嘟嘟 牧首 教宗 虎先生 小叶哥哥"
|
|
@@ -46,6 +47,7 @@ COLLOQUIAL_CORRECTIONS = {
|
|
| 46 |
}
|
| 47 |
|
| 48 |
def correct_colloquial_words(text: str) -> str:
|
|
|
|
| 49 |
for pattern, target in COLLOQUIAL_CORRECTIONS.items():
|
| 50 |
text = re.sub(pattern, target, text)
|
| 51 |
return text
|
|
@@ -72,16 +74,16 @@ def preprocess_audio(input_path: str, target_sr: int = 16000):
|
|
| 72 |
if len(y) == 0:
|
| 73 |
return input_path, 0.0
|
| 74 |
|
| 75 |
-
duration = len(y) / sr
|
| 76 |
|
| 77 |
# 适度频谱降噪 (强度 0.6)
|
| 78 |
try:
|
| 79 |
y = nr.reduce_noise(y=y, sr=sr, stationary=True, prop_decrease=0.6)
|
| 80 |
-
except Exception:
|
| 81 |
-
|
| 82 |
|
| 83 |
# 峰值归一化至 -0.5 dB
|
| 84 |
-
max_val = np.max(np.abs(y))
|
| 85 |
if max_val > 0:
|
| 86 |
y = (y / max_val) * 0.95
|
| 87 |
|
|
@@ -89,87 +91,93 @@ def preprocess_audio(input_path: str, target_sr: int = 16000):
|
|
| 89 |
sf.write(processed_path, y, sr, subtype="PCM_16")
|
| 90 |
return processed_path, duration
|
| 91 |
|
| 92 |
-
# ================= 4. 语调时序与情绪动态解析 =================
|
| 93 |
-
def extract_prosody_timeline(
|
| 94 |
"""
|
| 95 |
-
将 Emotion2Vec+ 的帧级时序概率矩阵聚合为易读的时间轴
|
|
|
|
| 96 |
"""
|
| 97 |
-
if not emo_res or len(emo_res) == 0:
|
| 98 |
-
return []
|
| 99 |
-
|
| 100 |
-
item = emo_res[0]
|
| 101 |
-
|
| 102 |
-
# 尝试提取帧级时序矩阵 (Shape: [T, Num_Classes] 或 scores 列表)
|
| 103 |
-
feats = item.get("feats") if "feats" in item else item.get("scores")
|
| 104 |
-
|
| 105 |
-
# 若模型仅返回整句级结果时的安全降级
|
| 106 |
-
if feats is None or not isinstance(feats, (list, np.ndarray)) or len(feats) == 0:
|
| 107 |
-
return [{
|
| 108 |
-
"start": 0.0,
|
| 109 |
-
"end": round(total_duration, 2),
|
| 110 |
-
"tone": item.get("key", item.get("emotion", "neutral")),
|
| 111 |
-
"confidence": round(float(max(item.get("scores", {}).values())), 4) if item.get("scores") else 1.0,
|
| 112 |
-
"energy_state": "moderate"
|
| 113 |
-
}]
|
| 114 |
-
|
| 115 |
-
feats = np.array(feats)
|
| 116 |
-
total_frames = len(feats)
|
| 117 |
-
if total_frames == 0:
|
| 118 |
-
return []
|
| 119 |
-
|
| 120 |
-
# 标签名称映射列表 (Emotion2Vec 典型标签索引)
|
| 121 |
labels = ["angry", "disgusted", "fearful", "happy", "neutral", "sad", "surprised"]
|
| 122 |
-
|
| 123 |
timeline = []
|
| 124 |
-
# 按 window_size (秒) 划分时间窗并计算均值
|
| 125 |
-
frames_per_window = max(1, int(total_frames * (window_size / max(total_duration, 0.1))))
|
| 126 |
-
|
| 127 |
-
num_windows = int(np.ceil(total_frames / frames_per_window))
|
| 128 |
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
start_time = round(i * window_size, 2)
|
| 137 |
-
end_time = round(min(total_duration, (i + 1) * window_size), 2)
|
| 138 |
-
|
| 139 |
-
# 找出当前时间段最突出的情绪/语调
|
| 140 |
-
top_idx = int(np.argmax(mean_scores)) if len(mean_scores) == len(labels) else 0
|
| 141 |
-
top_label = labels[top_idx] if top_idx < len(labels) else "neutral"
|
| 142 |
-
conf = float(mean_scores[top_idx]) if top_idx < len(mean_scores) else 0.5
|
| 143 |
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
"
|
| 158 |
-
"
|
| 159 |
-
"
|
| 160 |
-
|
|
|
|
|
|
|
| 161 |
|
| 162 |
return timeline
|
| 163 |
|
| 164 |
def evaluate_tone_and_emotion(text: str, emotion_scores: dict, raw_top_emotion: str):
|
| 165 |
"""全局综合语气判定:结合声学分数与文本关键词"""
|
| 166 |
-
top_score = emotion_scores.get(raw_top_emotion, 0.0)
|
| 167 |
|
| 168 |
# 撒娇 / 卖萌判定
|
| 169 |
-
happy_score = emotion_scores.get("happy", 0.0)
|
| 170 |
-
surprised_score = emotion_scores.get("surprised", 0.0)
|
| 171 |
has_soft_words = bool(re.search(r"[嘛啦呀吧啵呗嘤~~…]", text))
|
| 172 |
-
|
| 173 |
if (happy_score + surprised_score >= 0.55) and has_soft_words:
|
| 174 |
return "coquetry", top_score
|
| 175 |
|
|
@@ -225,23 +233,32 @@ async def transcribe_audio(
|
|
| 225 |
input=processed_path,
|
| 226 |
granularity="utterance"
|
| 227 |
)
|
| 228 |
-
|
| 229 |
raw_top_emotion = "neutral"
|
| 230 |
emotion_scores = {}
|
| 231 |
if emo_res_global and len(emo_res_global) > 0:
|
| 232 |
item = emo_res_global[0]
|
| 233 |
-
raw_top_emotion = item.get("key", item.get("emotion", "neutral"))
|
| 234 |
raw_scores = item.get("scores", {})
|
| 235 |
-
|
|
|
|
| 236 |
|
| 237 |
# 4. Emotion2Vec+ 提取帧级动态时序 (Frame-level)
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
|
|
|
|
|
|
|
|
|
| 243 |
# 生成语调变化时间轴 (按 0.5s 窗口聚合)
|
| 244 |
-
prosody_timeline = extract_prosody_timeline(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
|
| 246 |
# 5. 全局多模态语气裁决
|
| 247 |
final_emotion, confidence = evaluate_tone_and_emotion(
|
|
@@ -250,33 +267,32 @@ async def transcribe_audio(
|
|
| 250 |
raw_top_emotion
|
| 251 |
)
|
| 252 |
|
| 253 |
-
# 6.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
if response_format == "text":
|
| 255 |
return PlainTextResponse(clean_text)
|
| 256 |
elif response_format == "verbose_json":
|
| 257 |
-
|
| 258 |
"task": "transcribe",
|
| 259 |
-
"language": language,
|
| 260 |
-
"duration": round(duration, 2),
|
| 261 |
-
"text": clean_text,
|
| 262 |
-
"overall_emotion": final_emotion,
|
| 263 |
-
"confidence": round(float(confidence), 4),
|
| 264 |
-
"emotion_scores": emotion_scores,
|
| 265 |
-
"prosody_timeline": prosody_timeline,
|
| 266 |
"segments": []
|
| 267 |
})
|
|
|
|
| 268 |
else:
|
| 269 |
-
|
| 270 |
-
return JSONResponse({
|
| 271 |
-
"text": clean_text,
|
| 272 |
-
"duration": round(duration, 2),
|
| 273 |
-
"overall_emotion": final_emotion,
|
| 274 |
-
"confidence": round(float(confidence), 4),
|
| 275 |
-
"emotion_scores": emotion_scores,
|
| 276 |
-
"prosody_timeline": prosody_timeline
|
| 277 |
-
})
|
| 278 |
|
| 279 |
except Exception as e:
|
|
|
|
|
|
|
| 280 |
raise HTTPException(status_code=500, detail=str(e))
|
| 281 |
finally:
|
| 282 |
# 清理临时文件
|
|
|
|
| 2 |
import re
|
| 3 |
import shutil
|
| 4 |
import tempfile
|
| 5 |
+
import traceback
|
| 6 |
import numpy as np
|
| 7 |
import librosa
|
| 8 |
import soundfile as sf
|
|
|
|
| 29 |
)
|
| 30 |
return True
|
| 31 |
|
| 32 |
+
# 亲昵口语热词配置(完整保留原有全部人物与称呼)
|
| 33 |
CUSTOM_HOTWORDS = os.getenv(
|
| 34 |
"HOTWORDS",
|
| 35 |
"爹地 妈咪 学长 学姐 欧巴 欧尼 宝贝 亲爱的 哥哥 姐姐 弟弟 妹妹 师傅 老公 老婆 爹咪 小路 小路哥哥 因因 岚岚 甜甜 芝芝瓜 小选 小蛇 宝宝 叶瑄 艾因 路辰 罗夏 司岚 星星嘟嘟 牧首 教宗 虎先生 小叶哥哥"
|
|
|
|
| 47 |
}
|
| 48 |
|
| 49 |
def correct_colloquial_words(text: str) -> str:
|
| 50 |
+
"""自动修复口语常见同音错别字"""
|
| 51 |
for pattern, target in COLLOQUIAL_CORRECTIONS.items():
|
| 52 |
text = re.sub(pattern, target, text)
|
| 53 |
return text
|
|
|
|
| 74 |
if len(y) == 0:
|
| 75 |
return input_path, 0.0
|
| 76 |
|
| 77 |
+
duration = float(len(y) / sr)
|
| 78 |
|
| 79 |
# 适度频谱降噪 (强度 0.6)
|
| 80 |
try:
|
| 81 |
y = nr.reduce_noise(y=y, sr=sr, stationary=True, prop_decrease=0.6)
|
| 82 |
+
except Exception as e:
|
| 83 |
+
print(f"[Warn] 降噪跳过: {e}")
|
| 84 |
|
| 85 |
# 峰值归一化至 -0.5 dB
|
| 86 |
+
max_val = float(np.max(np.abs(y)))
|
| 87 |
if max_val > 0:
|
| 88 |
y = (y / max_val) * 0.95
|
| 89 |
|
|
|
|
| 91 |
sf.write(processed_path, y, sr, subtype="PCM_16")
|
| 92 |
return processed_path, duration
|
| 93 |
|
| 94 |
+
# ================= 4. 语调时序与情绪动态解析 (防崩安全版) =================
|
| 95 |
+
def extract_prosody_timeline(emo_frame_res, total_duration: float, fallback_emotion: str, window_size: float = 0.5):
|
| 96 |
"""
|
| 97 |
+
将 Emotion2Vec+ 的帧级时序概率矩阵安全聚合为易读的时间轴
|
| 98 |
+
强制转为 Python 原生 float/str 类型,杜绝 JSON 序列化崩溃
|
| 99 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
labels = ["angry", "disgusted", "fearful", "happy", "neutral", "sad", "surprised"]
|
|
|
|
| 101 |
timeline = []
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
|
| 103 |
+
try:
|
| 104 |
+
if not emo_frame_res or len(emo_frame_res) == 0:
|
| 105 |
+
raise ValueError("Empty emo_frame_res")
|
| 106 |
+
|
| 107 |
+
item = emo_frame_res[0]
|
| 108 |
+
raw_feats = item.get("feats") if "feats" in item else item.get("scores")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
|
| 110 |
+
if raw_feats is None:
|
| 111 |
+
raise ValueError("No feats or scores found in frame result")
|
| 112 |
+
|
| 113 |
+
feats = np.array(raw_feats, dtype=np.float32)
|
| 114 |
+
if feats.ndim == 1:
|
| 115 |
+
feats = np.expand_dims(feats, axis=0)
|
| 116 |
+
|
| 117 |
+
total_frames = feats.shape[0]
|
| 118 |
+
if total_frames == 0:
|
| 119 |
+
raise ValueError("Zero frames")
|
| 120 |
+
|
| 121 |
+
frames_per_window = max(1, int(total_frames * (window_size / max(total_duration, 0.1))))
|
| 122 |
+
num_windows = int(np.ceil(total_frames / frames_per_window))
|
| 123 |
+
|
| 124 |
+
for i in range(num_windows):
|
| 125 |
+
start_f = i * frames_per_window
|
| 126 |
+
end_f = min(total_frames, (i + 1) * frames_per_window)
|
| 127 |
+
chunk = feats[start_f:end_f]
|
| 128 |
+
|
| 129 |
+
mean_vec = np.mean(chunk, axis=0) if chunk.ndim > 1 else chunk
|
| 130 |
+
|
| 131 |
+
start_t = round(float(i * window_size), 2)
|
| 132 |
+
end_t = round(float(min(total_duration, (i + 1) * window_size)), 2)
|
| 133 |
+
|
| 134 |
+
if len(mean_vec) == len(labels):
|
| 135 |
+
top_idx = int(np.argmax(mean_vec))
|
| 136 |
+
top_tone = labels[top_idx]
|
| 137 |
+
conf = float(mean_vec[top_idx])
|
| 138 |
+
else:
|
| 139 |
+
top_tone = fallback_emotion
|
| 140 |
+
conf = 0.8
|
| 141 |
+
|
| 142 |
+
# 根据情绪类型判定当前声学唤醒度/语调状态
|
| 143 |
+
if top_tone in ["angry", "surprised"]:
|
| 144 |
+
energy_state = "high / rising" # 激昂 / 语调上扬
|
| 145 |
+
elif top_tone in ["sad"]:
|
| 146 |
+
energy_state = "low / falling" # 低沉 / 语调下沉
|
| 147 |
+
elif top_tone == "happy":
|
| 148 |
+
energy_state = "cheerful / dynamic" # 轻快 / 活跃
|
| 149 |
+
else:
|
| 150 |
+
energy_state = "moderate / flat" # 平稳 / 正常
|
| 151 |
+
|
| 152 |
+
timeline.append({
|
| 153 |
+
"start": start_t,
|
| 154 |
+
"end": end_t,
|
| 155 |
+
"tone": str(top_tone),
|
| 156 |
+
"confidence": round(float(conf), 4),
|
| 157 |
+
"energy_state": energy_state
|
| 158 |
+
})
|
| 159 |
|
| 160 |
+
except Exception as err:
|
| 161 |
+
print(f"[Warn] Timeline extraction fallback: {err}")
|
| 162 |
+
timeline = [{
|
| 163 |
+
"start": 0.0,
|
| 164 |
+
"end": round(float(total_duration), 2),
|
| 165 |
+
"tone": str(fallback_emotion),
|
| 166 |
+
"confidence": 0.8,
|
| 167 |
+
"energy_state": "moderate / flat"
|
| 168 |
+
}]
|
| 169 |
|
| 170 |
return timeline
|
| 171 |
|
| 172 |
def evaluate_tone_and_emotion(text: str, emotion_scores: dict, raw_top_emotion: str):
|
| 173 |
"""全局综合语气判定:结合声学分数与文本关键词"""
|
| 174 |
+
top_score = float(emotion_scores.get(raw_top_emotion, 0.0))
|
| 175 |
|
| 176 |
# 撒娇 / 卖萌判定
|
| 177 |
+
happy_score = float(emotion_scores.get("happy", 0.0))
|
| 178 |
+
surprised_score = float(emotion_scores.get("surprised", 0.0))
|
| 179 |
has_soft_words = bool(re.search(r"[嘛啦呀吧啵呗嘤~~…]", text))
|
| 180 |
+
|
| 181 |
if (happy_score + surprised_score >= 0.55) and has_soft_words:
|
| 182 |
return "coquetry", top_score
|
| 183 |
|
|
|
|
| 233 |
input=processed_path,
|
| 234 |
granularity="utterance"
|
| 235 |
)
|
| 236 |
+
|
| 237 |
raw_top_emotion = "neutral"
|
| 238 |
emotion_scores = {}
|
| 239 |
if emo_res_global and len(emo_res_global) > 0:
|
| 240 |
item = emo_res_global[0]
|
| 241 |
+
raw_top_emotion = str(item.get("key", item.get("emotion", "neutral")))
|
| 242 |
raw_scores = item.get("scores", {})
|
| 243 |
+
if isinstance(raw_scores, dict):
|
| 244 |
+
emotion_scores = {str(k): round(float(v), 4) for k, v in raw_scores.items()}
|
| 245 |
|
| 246 |
# 4. Emotion2Vec+ 提取帧级动态时序 (Frame-level)
|
| 247 |
+
try:
|
| 248 |
+
emo_res_frame = emo_model.generate(
|
| 249 |
+
input=processed_path,
|
| 250 |
+
granularity="frame"
|
| 251 |
+
)
|
| 252 |
+
except Exception:
|
| 253 |
+
emo_res_frame = []
|
| 254 |
+
|
| 255 |
# 生成语调变化时间轴 (按 0.5s 窗口聚合)
|
| 256 |
+
prosody_timeline = extract_prosody_timeline(
|
| 257 |
+
emo_res_frame,
|
| 258 |
+
duration,
|
| 259 |
+
raw_top_emotion,
|
| 260 |
+
window_size=0.5
|
| 261 |
+
)
|
| 262 |
|
| 263 |
# 5. 全局多模态语气裁决
|
| 264 |
final_emotion, confidence = evaluate_tone_and_emotion(
|
|
|
|
| 267 |
raw_top_emotion
|
| 268 |
)
|
| 269 |
|
| 270 |
+
# 6. 构造纯 Python 基础类型的安全字典
|
| 271 |
+
response_payload = {
|
| 272 |
+
"text": str(clean_text),
|
| 273 |
+
"duration": round(float(duration), 2),
|
| 274 |
+
"overall_emotion": str(final_emotion),
|
| 275 |
+
"confidence": round(float(confidence), 4),
|
| 276 |
+
"emotion_scores": emotion_scores,
|
| 277 |
+
"prosody_timeline": prosody_timeline
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
# 7. 按客户端格式输出
|
| 281 |
if response_format == "text":
|
| 282 |
return PlainTextResponse(clean_text)
|
| 283 |
elif response_format == "verbose_json":
|
| 284 |
+
response_payload.update({
|
| 285 |
"task": "transcribe",
|
| 286 |
+
"language": str(language),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
"segments": []
|
| 288 |
})
|
| 289 |
+
return JSONResponse(response_payload)
|
| 290 |
else:
|
| 291 |
+
return JSONResponse(response_payload)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
|
| 293 |
except Exception as e:
|
| 294 |
+
# 将完整错误堆栈打入 Hugging Face 日志,方便排查
|
| 295 |
+
traceback.print_exc()
|
| 296 |
raise HTTPException(status_code=500, detail=str(e))
|
| 297 |
finally:
|
| 298 |
# 清理临时文件
|