jjf1999 commited on
Commit
56bbbe8
·
verified ·
1 Parent(s): dadde25

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +88 -123
app.py CHANGED
@@ -6,7 +6,6 @@ import traceback
6
  import numpy as np
7
  import librosa
8
  import soundfile as sf
9
- import noisereduce as nr
10
  from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Depends, Security
11
  from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
12
  from fastapi.responses import JSONResponse, PlainTextResponse
@@ -29,13 +28,13 @@ def verify_token(credentials: HTTPAuthorizationCredentials = Security(security))
29
  )
30
  return True
31
 
32
- # 亲昵口语热词配置(完整保留原有全部人物与称呼)
33
  CUSTOM_HOTWORDS = os.getenv(
34
  "HOTWORDS",
35
- "爹地 妈咪 学长 学姐 欧巴 欧尼 宝贝 亲爱的 哥哥 姐姐 弟弟 妹妹 师傅 老公 老婆 爹咪 小路 小路哥哥 因因 岚岚 甜甜 芝芝瓜 小选 小蛇 宝宝 叶瑄 艾因 路辰 罗夏 司岚 星星嘟嘟 牧首 教宗 虎先生 小叶哥哥"
36
  )
37
 
38
- # 口语错别字纠错映射
39
  COLLOQUIAL_CORRECTIONS = {
40
  r"跌地|跌弟|爹底|迭地|嗲地": "爹地",
41
  r"学掌|鞋长|薛长|雪长": "学长",
@@ -47,41 +46,32 @@ COLLOQUIAL_CORRECTIONS = {
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
54
 
55
  # ================= 2. 加载模型 =================
56
- # ASR 文字与标点转录模型
57
  asr_model = AutoModel(
58
  model="iic/SenseVoiceSmall",
59
  device="cpu",
60
  disable_update=True
61
  )
62
 
63
- # 独立声学情绪与语调表征模型
64
  emo_model = AutoModel(
65
  model="iic/emotion2vec_plus_seed",
66
  device="cpu",
67
  disable_update=True
68
  )
69
 
70
- # ================= 3. 音频前处理流水线 =================
71
  def preprocess_audio(input_path: str, target_sr: int = 16000):
72
- """音频标准化:转 16kHz 单声道 -> 降噪 -> 峰值归一化,并返回总时长"""
73
  y, sr = librosa.load(input_path, sr=target_sr, mono=True)
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:
@@ -91,89 +81,90 @@ def preprocess_audio(input_path: str, target_sr: int = 16000):
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))
@@ -181,7 +172,6 @@ def evaluate_tone_and_emotion(text: str, emotion_scores: dict, raw_top_emotion:
181
  if (happy_score + surprised_score >= 0.55) and has_soft_words:
182
  return "coquetry", top_score
183
 
184
- # 低沉嗓音中性保底
185
  if top_score < 0.60:
186
  return "neutral", top_score
187
 
@@ -192,7 +182,7 @@ def evaluate_tone_and_emotion(text: str, emotion_scores: dict, raw_top_emotion:
192
  async def root():
193
  return {
194
  "status": "online",
195
- "service": "SenseVoice + Emotion2Vec+ Dynamic Prosody API",
196
  "docs": "/docs",
197
  "endpoint": "/v1/audio/transcriptions"
198
  }
@@ -212,72 +202,49 @@ async def transcribe_audio(
212
 
213
  processed_path = None
214
  try:
215
- # 1. 音频标准化前处理
216
  processed_path, duration = preprocess_audio(raw_path)
217
 
218
- # 2. SenseVoice 识别文本(带热词增强)
219
  asr_res = asr_model.generate(
220
  input=processed_path,
221
  language=language if language in ["zh", "en", "yue", "ja", "ko"] else "auto",
222
  use_itn=True,
223
- emotion=False,
224
- event=False,
225
  hotword=CUSTOM_HOTWORDS
226
  )
227
  raw_text = asr_res[0]["text"] if asr_res and len(asr_res) > 0 else ""
228
  clean_text = re.sub(r"<\|.*?\|>", "", raw_text).strip()
229
  clean_text = correct_colloquial_words(clean_text)
230
 
231
- # 3. Emotion2Vec+ 提取全局情绪 (Utterance-level)
232
- emo_res_global = emo_model.generate(
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(
265
  clean_text,
266
  emotion_scores,
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":
@@ -291,11 +258,9 @@ async def transcribe_audio(
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
- # 清理临时文件
299
  if os.path.exists(raw_path):
300
  os.remove(raw_path)
301
  if processed_path and os.path.exists(processed_path):
 
6
  import numpy as np
7
  import librosa
8
  import soundfile as sf
 
9
  from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Depends, Security
10
  from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
11
  from fastapi.responses import JSONResponse, PlainTextResponse
 
28
  )
29
  return True
30
 
31
+ # 亲昵口语热词配置
32
  CUSTOM_HOTWORDS = os.getenv(
33
  "HOTWORDS",
34
+ "爹地 妈咪 Daddy 学长 学姐 欧巴 欧尼 宝贝 亲爱的 哥哥 姐姐 弟弟 妹妹 师傅 老公 老婆 爹咪 小路 小路哥哥 因因 岚岚 甜甜 芝芝瓜 小选 小蛇 宝宝 叶瑄 艾因 路辰 罗夏 司岚 星星嘟嘟 牧首 教宗 虎先生 小叶哥哥"
35
  )
36
 
37
+ # 口语错别字规则纠错映射
38
  COLLOQUIAL_CORRECTIONS = {
39
  r"跌地|跌弟|爹底|迭地|嗲地": "爹地",
40
  r"学掌|鞋长|薛长|雪长": "学长",
 
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
52
 
53
  # ================= 2. 加载模型 =================
 
54
  asr_model = AutoModel(
55
  model="iic/SenseVoiceSmall",
56
  device="cpu",
57
  disable_update=True
58
  )
59
 
 
60
  emo_model = AutoModel(
61
  model="iic/emotion2vec_plus_seed",
62
  device="cpu",
63
  disable_update=True
64
  )
65
 
66
+ # ================= 3. 无损音频标准化 =================
67
  def preprocess_audio(input_path: str, target_sr: int = 16000):
68
+ """仅做重采样与响度归一化,严禁有损频域降噪以保护 ASR 精度"""
69
  y, sr = librosa.load(input_path, sr=target_sr, mono=True)
70
  if len(y) == 0:
71
  return input_path, 0.0
72
 
73
  duration = float(len(y) / sr)
74
 
 
 
 
 
 
 
75
  # 峰值归一化至 -0.5 dB
76
  max_val = float(np.max(np.abs(y)))
77
  if max_val > 0:
 
81
  sf.write(processed_path, y, sr, subtype="PCM_16")
82
  return processed_path, duration
83
 
84
+ # ================= 4. 情绪与时序解析 =================
85
+ def parse_emotion_and_timeline(emo_res, total_duration: float, window_size: float = 0.5):
86
+ """统一提取全局情绪与时序走势"""
 
 
 
87
  labels = ["angry", "disgusted", "fearful", "happy", "neutral", "sad", "surprised"]
88
+
89
+ top_emotion = "neutral"
90
+ emotion_scores = {}
91
  timeline = []
92
+
93
+ if not emo_res or len(emo_res) == 0:
94
+ return top_emotion, emotion_scores, timeline
95
+
96
+ item = emo_res[0]
97
+ top_emotion = str(item.get("key", item.get("emotion", "neutral")))
98
 
99
+ # 提取置信度
100
+ raw_scores = item.get("scores", {})
101
+ if isinstance(raw_scores, dict):
102
+ emotion_scores = {str(k): round(float(v), 4) for k, v in raw_scores.items()}
103
+
104
+ # 提取时间轴
105
+ raw_feats = item.get("feats") if "feats" in item else None
106
+ if raw_feats is not None:
107
+ try:
108
+ feats = np.array(raw_feats, dtype=np.float32)
109
+ if feats.ndim == 1:
110
+ feats = np.expand_dims(feats, axis=0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
 
112
+ total_frames = feats.shape[0]
113
+ if total_frames > 0:
114
+ frames_per_window = max(1, int(total_frames * (window_size / max(total_duration, 0.1))))
115
+ num_windows = int(np.ceil(total_frames / frames_per_window))
116
+
117
+ for i in range(num_windows):
118
+ start_f = i * frames_per_window
119
+ end_f = min(total_frames, (i + 1) * frames_per_window)
120
+ chunk = feats[start_f:end_f]
121
+ mean_vec = np.mean(chunk, axis=0) if chunk.ndim > 1 else chunk
122
+
123
+ start_t = round(float(i * window_size), 2)
124
+ end_t = round(float(min(total_duration, (i + 1) * window_size)), 2)
125
+
126
+ if len(mean_vec) == len(labels):
127
+ t_idx = int(np.argmax(mean_vec))
128
+ t_label = labels[t_idx]
129
+ conf = float(mean_vec[t_idx])
130
+ else:
131
+ t_label = top_emotion
132
+ conf = 0.8
133
+
134
+ state = "moderate / flat"
135
+ if t_label in ["angry", "surprised"]:
136
+ state = "high / rising"
137
+ elif t_label in ["sad"]:
138
+ state = "low / falling"
139
+ elif t_label == "happy":
140
+ state = "cheerful / dynamic"
141
+
142
+ timeline.append({
143
+ "start": start_t,
144
+ "end": end_t,
145
+ "tone": str(t_label),
146
+ "confidence": round(float(conf), 4),
147
+ "energy_state": state
148
+ })
149
+ except Exception as e:
150
+ print(f"[Warn] Timeline parse failed: {e}")
151
+
152
+ # 若无有效帧数据则生成单段保底
153
+ if not timeline:
154
+ timeline.append({
155
  "start": 0.0,
156
  "end": round(float(total_duration), 2),
157
+ "tone": top_emotion,
158
+ "confidence": round(float(max(emotion_scores.values())), 4) if emotion_scores else 1.0,
159
  "energy_state": "moderate / flat"
160
+ })
161
 
162
+ return top_emotion, emotion_scores, timeline
163
 
164
  def evaluate_tone_and_emotion(text: str, emotion_scores: dict, raw_top_emotion: str):
165
+ """多模态语气判断"""
166
  top_score = float(emotion_scores.get(raw_top_emotion, 0.0))
167
 
 
168
  happy_score = float(emotion_scores.get("happy", 0.0))
169
  surprised_score = float(emotion_scores.get("surprised", 0.0))
170
  has_soft_words = bool(re.search(r"[嘛啦呀吧啵呗嘤~~…]", text))
 
172
  if (happy_score + surprised_score >= 0.55) and has_soft_words:
173
  return "coquetry", top_score
174
 
 
175
  if top_score < 0.60:
176
  return "neutral", top_score
177
 
 
182
  async def root():
183
  return {
184
  "status": "online",
185
+ "service": "SenseVoice + Emotion2Vec+ STT API",
186
  "docs": "/docs",
187
  "endpoint": "/v1/audio/transcriptions"
188
  }
 
202
 
203
  processed_path = None
204
  try:
205
+ # 1. 无损标准化前处理
206
  processed_path, duration = preprocess_audio(raw_path)
207
 
208
+ # 2. SenseVoice 原生调用(标准 ITN,不硬传干扰参数)
209
  asr_res = asr_model.generate(
210
  input=processed_path,
211
  language=language if language in ["zh", "en", "yue", "ja", "ko"] else "auto",
212
  use_itn=True,
 
 
213
  hotword=CUSTOM_HOTWORDS
214
  )
215
  raw_text = asr_res[0]["text"] if asr_res and len(asr_res) > 0 else ""
216
  clean_text = re.sub(r"<\|.*?\|>", "", raw_text).strip()
217
  clean_text = correct_colloquial_words(clean_text)
218
 
219
+ # 3. Emotion2Vec+ 单次推理获取情绪与特征
220
+ emo_res = emo_model.generate(
221
  input=processed_path,
222
  granularity="utterance"
223
  )
224
+ raw_top_emotion, emotion_scores, prosody_timeline = parse_emotion_and_timeline(
225
+ emo_res,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
  duration,
 
227
  window_size=0.5
228
  )
229
 
230
+ # 4. 多模态综合语气裁决
231
  final_emotion, confidence = evaluate_tone_and_emotion(
232
  clean_text,
233
  emotion_scores,
234
  raw_top_emotion
235
  )
236
 
237
+ # 5. 构造双重兼容的返回体(同时保留 emotion 和 overall_emotion)
238
  response_payload = {
239
  "text": str(clean_text),
240
  "duration": round(float(duration), 2),
241
+ "emotion": str(final_emotion), # 兼容旧版及常规调用
242
+ "overall_emotion": str(final_emotion), # 兼容时序扩展
243
  "confidence": round(float(confidence), 4),
244
  "emotion_scores": emotion_scores,
245
  "prosody_timeline": prosody_timeline
246
  }
247
 
 
248
  if response_format == "text":
249
  return PlainTextResponse(clean_text)
250
  elif response_format == "verbose_json":
 
258
  return JSONResponse(response_payload)
259
 
260
  except Exception as e:
 
261
  traceback.print_exc()
262
  raise HTTPException(status_code=500, detail=str(e))
263
  finally:
 
264
  if os.path.exists(raw_path):
265
  os.remove(raw_path)
266
  if processed_path and os.path.exists(processed_path):