File size: 16,872 Bytes
29ad738
fa5a8d9
0b157a9
 
dadde25
fa5a8d9
 
 
0b157a9
 
 
 
29ad738
01fd746
29ad738
3ffcc0f
0b157a9
 
 
 
45bc5d0
0b157a9
 
 
 
 
 
 
 
 
4f6e4e1
3ffcc0f
 
5bdf942
3ffcc0f
 
4f6e4e1
3ffcc0f
 
 
 
 
 
 
 
 
 
 
45bc5d0
3ffcc0f
 
 
 
45bc5d0
 
 
 
 
 
 
3c09dd7
 
 
 
053b8cd
 
3c09dd7
 
fa5a8d9
3c09dd7
29ad738
 
 
 
3c09dd7
fa5a8d9
3c09dd7
fa5a8d9
 
 
 
5bdf942
520df37
5bdf942
fa5a8d9
 
520df37
fa5a8d9
dadde25
520df37
5bdf942
dadde25
fa5a8d9
 
 
 
 
520df37
fa5a8d9
f30cb24
d6a6276
 
f30cb24
 
 
 
 
 
d6a6276
 
 
 
 
f30cb24
d6a6276
 
 
 
 
 
f30cb24
d6a6276
f30cb24
d6a6276
 
f30cb24
d6a6276
f30cb24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d6a6276
 
 
 
f30cb24
d6a6276
 
f30cb24
 
d6a6276
 
 
 
731cbd3
 
053b8cd
 
 
 
 
731cbd3
053b8cd
 
 
d6a6276
45bc5d0
56bbbe8
731cbd3
520df37
56bbbe8
 
731cbd3
 
 
 
 
 
 
56bbbe8
 
731cbd3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97586fc
053b8cd
5bdf942
053b8cd
 
 
5bdf942
731cbd3
5bdf942
053b8cd
 
 
45bc5d0
56bbbe8
 
 
 
 
 
dadde25
56bbbe8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
731cbd3
56bbbe8
d6a6276
5bdf942
 
56bbbe8
 
 
5bdf942
56bbbe8
5bdf942
56bbbe8
 
 
 
 
 
dadde25
 
5bdf942
731cbd3
5bdf942
56bbbe8
520df37
56bbbe8
520df37
 
5bdf942
 
 
 
 
dadde25
25d4332
dadde25
 
25d4332
dadde25
d6a6276
731cbd3
 
25d4332
d6a6276
5bdf942
 
 
 
25d4332
 
d6a6276
01fd746
 
 
 
 
 
 
 
 
 
 
 
45bc5d0
01fd746
 
 
 
 
 
 
 
 
 
d6a6276
01fd746
 
 
 
7c902dd
 
01fd746
 
 
d6a6276
fa5a8d9
 
 
 
45bc5d0
fa5a8d9
 
 
 
0b157a9
 
 
 
4f6e4e1
0b157a9
25d4332
0b157a9
 
 
 
fa5a8d9
0b157a9
fa5a8d9
0b157a9
5bdf942
520df37
fa5a8d9
4f6e4e1
45bc5d0
fa5a8d9
 
de9df93
adbe4db
3ffcc0f
0b157a9
fa5a8d9
 
45bc5d0
f30cb24
d6a6276
 
 
3ffcc0f
45bc5d0
0b157a9
d6a6276
56bbbe8
fa5a8d9
 
 
56bbbe8
 
dadde25
d6a6276
dadde25
520df37
d6a6276
25d4332
 
 
 
 
fa5a8d9
d6a6276
01fd746
 
 
 
 
 
 
 
dadde25
0b157a9
01fd746
0b157a9
01fd746
0b157a9
de9df93
01fd746
 
 
 
 
 
 
0b157a9
 
 
01fd746
 
 
 
 
 
 
 
 
0b157a9
 
dadde25
0b157a9
 
fa5a8d9
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
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 路由端点 =================
@app.get("/")
async def root():
    return {
        "status": "online",
        "service": "SenseVoice + Emotion2Vec+ Unified Report API",
        "docs": "/docs",
        "endpoint": "/v1/audio/transcriptions"
    }

@app.post("/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)