diff --git a/web-pages/product-site/content/legacy-manifest.json b/web-pages/product-site/content/legacy-manifest.json index 3815e2ab4..f2724fdd5 100644 --- a/web-pages/product-site/content/legacy-manifest.json +++ b/web-pages/product-site/content/legacy-manifest.json @@ -3,6 +3,7 @@ "source": "https://www.funasr.com/ public static corpus", "captured": "2026-07-26", "files": { + "img/sensevoice-tags-waveform.png": "22f2a380a96bfc675751eafab7ce3b731e006001f6b5e61295c03bf26d8e7405", "blog/sensevoice-finetuning-acceptance.html": "df48083c643321de767b17f9055bd28e26552974e6fb97c834ba3680b8ff0612", "en/blog/sensevoice-finetuning-acceptance.html": "a45590971d7b41fb126b59053fa4bf25b7a9882d8d9b4a73c7ed982b2fb01f62", "img/continual-eval-example.png": "20b7a0de64fdd08ea761d67a7ccd7ce9a421f3669d9a212b176124630cc0f259", @@ -39,7 +40,7 @@ "blog/self-hosted-google-aws-azure-speech-to-text.html": "8e4000fb9e2b33a48a3907d4bd0f55db57ba74121a89b5fa12b412c3c963f49a", "blog/self-hosted-openai-whisper-api-alternative.html": "254184cd753f30e308329c301ae47e8f5f5dfb57c01d0abf3ce341034f2990f9", "blog/sensevoice-deployment-guide.html": "61c34fb7626444a0f1b5c93013dd95c10c576333791eaca1ab819baf0a8cdad6", - "blog/sensevoice-emotion-language-detection.html": "a00c40bfbaa3f0f390bc00f052b7e4e299a7d193b088014b7515813f1f8a636b", + "blog/sensevoice-emotion-language-detection.html": "2451769a28af67788456aa02dad5201d218ac53fb84764cd7fbc32f991c953f8", "blog/speech-to-text-python-transcribe-audio.html": "87335710235dfdec9e01288f03a9a294c7bc3f730110e8d06f2b4c9d4906e284", "blog/speech-to-text-timestamps-python.html": "78364db117dfbce45b03739679764cec7ab1d0a860f321c67154f33392fc6b8d", "blog/subtitle-edit-fun-asr-sensevoice-local-subtitles.html": "f8d5870b6cf9dbdfd4e943fdccb7ae8f091472f1dcbd46bd7db90bd36e3e7846", @@ -81,7 +82,7 @@ "en/blog/self-hosted-google-aws-azure-speech-to-text.html": "4a8453451722b3ec8735224a586744ddfbfec9458cedd433f9fc908351027e9a", "en/blog/self-hosted-openai-whisper-api-alternative.html": "14bdb877adfd113eeb04b215ffa015c29d8323859eeddd00d9e03d166dfa0473", "en/blog/sensevoice-deployment-guide.html": "8882912c6a84ec0a0e025d0eff8fcfa74bf76324bd4c9cae862c43c87c6e10b3", - "en/blog/sensevoice-emotion-language-detection.html": "4fa0d49d34df3b923323fc49fbb02c432efd7cd0d90d81e84621dbbecc08cd29", + "en/blog/sensevoice-emotion-language-detection.html": "8f1432a4c07dab895dfda144d4fe1f64a487c72922febb6a379a15b7215e42a3", "en/blog/speech-to-text-python-transcribe-audio.html": "4fc57aef3c6177e28afa26fa6c41f67280b986e384b32a63a2f79ca67bcfe631", "en/blog/speech-to-text-timestamps-python.html": "6a7f8368f757c9ea50e287db6db3191ebcbbfc914f9a869b9515648b2d69790b", "en/blog/subtitle-edit-fun-asr-sensevoice-local-subtitles.html": "7c65a73bab1ff481d9b398476ea6440926546ba7122cc2cb64f5bda81fc039a2", diff --git a/web-pages/product-site/data/blog.json b/web-pages/product-site/data/blog.json index 86d79e3e6..4ee2299eb 100644 --- a/web-pages/product-site/data/blog.json +++ b/web-pages/product-site/data/blog.json @@ -402,14 +402,14 @@ { "slug": "sensevoice-emotion-language-detection", "category": "explanations", - "reviewed": false, + "reviewed": true, "zh": { - "title": "文字之外,语音里还有什么信息", - "summary": "" + "title": "SenseVoice 的情绪标签,该怎么用?", + "summary": "保留原始预测,区分显示文本与评测标签,不把分类结果当作人的真实情绪。" }, "en": { - "title": "What can speech tell us beyond the words?", - "summary": "" + "title": "How should you use SenseVoice emotion tags?", + "summary": "Keep raw predictions, separate display from evaluation, and do not mistake a tag for a person's true state." } }, { diff --git a/web-pages/product-site/legacy/blog/sensevoice-emotion-language-detection.html b/web-pages/product-site/legacy/blog/sensevoice-emotion-language-detection.html index 25a7c264f..1b90a0bd3 100644 --- a/web-pages/product-site/legacy/blog/sensevoice-emotion-language-detection.html +++ b/web-pages/product-site/legacy/blog/sensevoice-emotion-language-detection.html @@ -1,158 +1,57 @@ - - - - -Python 语音情感识别:一个模型搞定语种 + 情感 + 音频事件(SenseVoice)| FunASR 博客 - - - - - - - - - - - - - - - - - - + + +SenseVoice 的情绪标签,该怎么用? | FunASR + + + + + + +
-

Python 语音情感识别:一个模型同时输出语种、情感与音频事件(SenseVoice)

- - -

大多数语音识别(ASR)模型只给你文字。但真实场景里,你往往还想知道:说话人是什么情绪?这段是哪种语言?背景是纯人声还是带音乐?用 Whisper 你得叠一堆东西——语种检测 + 单独的情感模型 + 音频事件分类器,既慢又难维护。

-

SenseVoice 是 FunAudioLLM 团队开源的多语种语音理解模型。它在一次非自回归前向里就同时返回:转写文本、语种识别情感识别音频事件检测,外加逆文本归一化(ITN)。架构非自回归,适合低延迟和批量推理;实际吞吐取决于硬件、音频长度和 batch。

- -

一次推理能拿到什么

- - - - - - - -
能力说明
语音识别(ASR)中、粤、英、日、韩五种语言,中文准确率领先
语种识别(LID)自动判别 zh / en / ja / ko / yue …
情感识别HAPPY 😊 / SAD 😔 / ANGRY 😡 / NEUTRAL / FEARFUL / DISGUSTED / SURPRISED
音频事件Speech / BGM 🎵 / Applause 👏 / Laughter / Cry
逆文本归一化(ITN)“九点”→“9点”、“fifty”→“50”
- -

3 行代码跑通

-
pip install funasr
-
-from funasr import AutoModel
+

SenseVoice 的情绪标签,该怎么用?

+

2026-09-10 修订 · 技术解读 · 阅读约 5 分钟

+

你把一段客服录音转成文字,发现前面还有 ANGRY 或 NEUTRAL。能不能据此自动判断客户是否生气?先别急着给人下结论:这些是模型对音频片段的分类预测。接入应用前,先分清原始预测、显示文本和评测依据。

+

情绪标签不是人的真实心理状态、身份或诊断,也不是已经校准的置信度。它可以作为经过验证后的辅助检索线索,但不能单独决定员工考核、客户处置或其他影响人的重要结论。

+

先保留原始结果

+

SenseVoiceSmall 在转写之外还会生成语种、情绪、音频事件及文本规范化标签。下面用短音频观察输出,不接 VAD、不做说话人分离。将有权使用的短录音放到 audio.wav;第一次使用模型名时需要下载权重。

+

先按维护中的安装与环境验证指南准备 CPU 环境,确认 PyTorch 与 torchaudio 可以成功导入且版本配套。python -m pip install "funasr==1.4.15" 只固定 FunASR 包,不是完整的新环境安装步骤。

+
from funasr import AutoModel
 from funasr.utils.postprocess_utils import rich_transcription_postprocess
 
-model = AutoModel(model="iic/SenseVoiceSmall", disable_update=True)
-res = model.generate(input="audio.wav", cache={}, language="auto", use_itn=True)
-
-print(res[0]["text"])                                    # 原始带标签输出
-print(rich_transcription_postprocess(res[0]["text"]))    # 清洗后的纯文本
- -

真实输出:5 种语言自动识别

-

用 SenseVoice 自带的多语种示例音频实测(zh/en/ja/ko/yue),原始输出每段开头都带标签:

-
<|zh|><|NEUTRAL|><|Speech|><|withitn|>开放时间早上9点至下午5点。
-<|en|><|NEUTRAL|><|Speech|><|withitn|>The tribal chieftain called for the boy and presented him with 50 pieces of gold.
-<|ja|><|NEUTRAL|><|Speech|><|withitn|>うちの中学は弁当制で持っていけない場合は、50 円の学校販売のパンを買う。
-<|ko|><|NEUTRAL|><|Speech|><|withitn|>조금만 생각을 하면서 살면 훨씬 편할 거야.
-<|yue|><|NEUTRAL|><|Speech|><|withitn|>呢几个字都表达唔到,我想讲嘅意思。
-

注意每段开头的标签:<|zh|> 语种(连粤语 yue 都能分出来)、<|NEUTRAL|> 情感、<|Speech|> 音频事件、<|withitn|> 表示已做 ITN(“9点至下午5点”“50 pieces”)。

- -

情感与音频事件:真实音频实测

-

把 SenseVoice 跑在 60 段真实网络音频上:其中 56 段被正确标为 BGM(带背景音乐)——这正是 Whisper 会硬转写、容易幻觉的场景;同时检测出 HAPPY / ANGRY / NEUTRAL 等情感。例如一段被标为 ANGRY 的语音:

-
<|zh|><|ANGRY|><|Speech|><|withitn|>哎,不要看不起那些理想主义者,你脚下的每一步都是他们走出来的。
-

完整标签集——情感:HAPPY / SAD / ANGRY / NEUTRAL / FEARFUL / DISGUSTED / SURPRISED;事件:Speech / BGM / Applause / Laughter / Cry。

- -

如何解析这些标签

-
import re
-
-raw = res[0]["text"]
-tags = re.findall(r"<\|([^|]+)\|>", raw)
-language = tags[0] if tags else None                       # 'zh'
-emotion  = next((t for t in tags if t in
-            {"HAPPY","SAD","ANGRY","NEUTRAL","FEARFUL","DISGUSTED","SURPRISED"}), None)
-event    = next((t for t in tags if t in
-            {"Speech","BGM","Applause","Laughter","Cry"}), None)
-text     = re.sub(r"<\|[^|]+\|>", "", raw)         # 纯文本
-print(language, emotion, event, text)
- -

对比 Whisper

- - - - - - - -
SenseVoiceWhisper
转写 + 语种✅ 一次搞定
情感识别✅ 内置❌ 需另接模型
音频事件(BGM/掌声/笑声)✅ 内置
逆文本归一化✅ 内置部分
速度非自回归,约快 15×自回归基准
-

要的不只是文字,而是对音频的理解时,SenseVoice 一个模型就够了——无需再拼语种检测 + 情感模型 + 事件分类器。

- -

FunASR 全家桶都是开源的——工业级 ASR / VAD / 标点 / 说话人 / 情感事件 / LLM-ASR。觉得有用就点个 Star 支持一下 👇

⭐ Star FunASR

也欢迎 Star:SenseVoice · Fun-ASR · FunClip

- -

相关文章

- -
- - - \ No newline at end of file +model = AutoModel( + model="iic/SenseVoiceSmall", + device="cpu", + disable_update=True, +) +raw = model.generate( + input="audio.wav", + language="auto", + use_itn=True, + ban_emo_unk=False, +)[0]["text"] + +print("raw:", raw) +print("display:", rich_transcription_postprocess(raw)) +

在 Linux CPU、FunASR 1.4.15、PyTorch/torchaudio 2.10.0 环境中,用官方中文短音频得到下面的真实读数。音频约 5.55 秒,使用已缓存的 SenseVoiceSmall 权重;这是一项功能检查,不是情绪准确率或速度 benchmark。

+
官方中文短音频的实际波形,横轴为时间,纵轴为振幅
输入音频的真实波形,不是情绪强度、模型注意力或置信度曲线。
+

原始预测

+
<|zh|><|NEUTRAL|><|Speech|><|withitn|>欢迎大家来体验达摩院推出的语音识别模型。
+

用于显示的文本

+
欢迎大家来体验达摩院推出的语音识别模型。
+

zh 是这里预测的语种,NEUTRAL 是情绪类别,Speech 是音频事件标签,withitn 表示文本规范化模式。它们都不是“识别正确”的证明;Speech 也不保证整段没有音乐或噪声。这个例子没有人工情绪真值,不能据此计算情绪准确率。

+

显示文本不能当评测标签

+

rich_transcription_postprocess() 让结果适合显示,并不承诺只保留纯文字。它可能去掉控制标签,也可能把情绪或事件转成 emoji。下面是人为构造的标签字符串,不是另一段录音的预测;用同一版本后处理函数即可验证区别。

+
输入: <|zh|><|HAPPY|><|Speech|><|withitn|>今天放假。
+显示: 今天放假。😊
+

把显示文本切开、寻找第几个词来当情绪类别,会丢失或误读标签。先保存 res[0]["text"] 这类原始输出,再按任务解析;显示版本单独存储。多个切片可能给出不同预测,也不能把整段的一个标签当作每个时刻或每个说话人的状态。

+

不要把 ban_emo_unk=True 理解为“更准确”。实现只是屏蔽未知情绪 token 的解码分数;不允许输出未知,不等于模型获得了新证据。示例显式保留未知选项,未知或无法解析的结果应进入检查流程,不能默默改成 NEUTRAL。

+

需要分数时,先固定评测口径

+

CASIA / RAVDESS 社区复现问题仍未解决;本文没有复现论文分数,也没有访问或分发这些数据集。比较前先固定合法数据集版本、样本清单、类别映射、权重散列、软件版本和指标定义。

+

现有 SER 工具从原始输出读取六类评测标签,规范化 fear/fearful、surprise/surprised,遇到没有受支持标签的输出会报错,而不是静默丢样本。这个六类评测契约不是完整模型词表,不能直接当作任何应用的通用情绪解析器。

+

它同时报告 WA(所有样本的准确率)与 UA(清单中各类别召回率的平均)。一个合成计数例子:9 条 neutral、1 条 angry 全部预测为 neutral,WA 是 90%,UA 只有 50%。这只是用维护中的指标函数核验口径,不是 SenseVoice 的测量结果。一个总分可能掩盖少数类别完全失败。

+

应用侧先抽样回听,覆盖自己的语言、噪声、设备与说话风格,记录误报和未知,再决定是否采用辅助标签。需要“谁在何时说了什么”,应走说话人及时间覆盖的验收路径,不是从情绪 tag 推断身份。

+

下一步,打开固定版本的SenseVoice SER 评测说明,从你的合法样本清单开始,保留原始输出与未通过的样本。

+ + diff --git a/web-pages/product-site/legacy/en/blog/sensevoice-emotion-language-detection.html b/web-pages/product-site/legacy/en/blog/sensevoice-emotion-language-detection.html index 6dc5e3c62..2664b347f 100644 --- a/web-pages/product-site/legacy/en/blog/sensevoice-emotion-language-detection.html +++ b/web-pages/product-site/legacy/en/blog/sensevoice-emotion-language-detection.html @@ -1,158 +1,57 @@ - - - - -Speech Emotion Recognition in Python — Language ID & Audio Events in One Model (SenseVoice) | FunASR Blog - - - - - - - - - - - - - - - - - - + + +How should you use SenseVoice emotion tags? | FunASR + + + + + + +
-

Speech Emotion Recognition in Python — Language ID & Audio Events in One Model (SenseVoice)

- - -

Most speech-recognition (ASR) models give you only text. But real applications often need more: what emotion is the speaker in? what language is this? is the background clean speech or music? With Whisper you have to stack several models — language detection + a separate emotion model + an audio-event classifier. Slow and hard to maintain.

-

SenseVoice (an open-source multilingual speech-understanding model from the FunAudioLLM team) returns all of it in one non-autoregressive forward pass: the transcript plus spoken-language ID, emotion, audio-event detection, and inverse text normalization (ITN). Its non-autoregressive architecture suits low-latency and batch inference; benchmark throughput on the target hardware and audio distribution.

- -

What one inference gives you

- - - - - - - -
CapabilityDetail
ASRMandarin, Cantonese, English, Japanese, and Korean, leading Chinese accuracy
Language IDauto-detects zh / en / ja / ko / yue …
EmotionHAPPY 😊 / SAD 😔 / ANGRY 😡 / NEUTRAL / FEARFUL / DISGUSTED / SURPRISED
Audio eventsSpeech / BGM 🎵 / Applause 👏 / Laughter / Cry
Inverse text norm (ITN)“nine o'clock”→“9:00”, “fifty”→“50”
- -

Three lines of Python

-
pip install funasr
-
-from funasr import AutoModel
+

How should you use SenseVoice emotion tags?

+

Revised 2026-09-10 · Technical explanation · 5 min read

+

A customer-call transcript starts with ANGRY or NEUTRAL. Can your application use that to decide how the customer feels? First separate three things: the model's prediction for an audio segment, the text you display, and the evidence used to evaluate it.

+

An emotion tag is not a person's true mental state, identity or diagnosis, nor a calibrated confidence score. After validation, it may help a human find recordings to review. It should not independently determine employee evaluations, customer treatment or other consequential decisions about people.

+

Keep the raw result first

+

SenseVoiceSmall produces language, emotion, audio-event and text-normalization tags alongside transcription. This short-audio example uses neither VAD nor speaker diarization. Place a short recording you are entitled to use at audio.wav; the first model-ID load needs to download weights.

+

First prepare a CPU environment using the maintained installation and environment checks. Verify that PyTorch and torchaudio import successfully and have compatible versions. python -m pip install "funasr==1.4.15" only pins the FunASR package; it is not a complete fresh-environment installation.

+
from funasr import AutoModel
 from funasr.utils.postprocess_utils import rich_transcription_postprocess
 
-model = AutoModel(model="iic/SenseVoiceSmall", disable_update=True)
-res = model.generate(input="audio.wav", cache={}, language="auto", use_itn=True)
-
-print(res[0]["text"])                                    # raw, with tags
-print(rich_transcription_postprocess(res[0]["text"]))    # cleaned text
- -

Real output: 5 languages auto-detected

-

Run on SenseVoice's bundled multilingual samples (zh/en/ja/ko/yue). Each raw output starts with tags:

-
<|zh|><|NEUTRAL|><|Speech|><|withitn|>开放时间早上9点至下午5点。
-<|en|><|NEUTRAL|><|Speech|><|withitn|>The tribal chieftain called for the boy and presented him with 50 pieces of gold.
-<|ja|><|NEUTRAL|><|Speech|><|withitn|>うちの中学は弁当制で持っていけない場合は、50 円の学校販売のパンを買う。
-<|ko|><|NEUTRAL|><|Speech|><|withitn|>조금만 생각을 하면서 살면 훨씬 편할 거야.
-<|yue|><|NEUTRAL|><|Speech|><|withitn|>呢几个字都表达唔到,我想讲嘅意思。
-

Note the leading tags: <|zh|> language (it even separates Cantonese yue), <|NEUTRAL|> emotion, <|Speech|> audio event, <|withitn|> ITN applied (“9点至下午5点”, “50 pieces”).

- -

Emotion & audio events on real-world audio

-

Running SenseVoice over 60 real-world web clips: 56 were correctly tagged BGM (background music present) — exactly the case where Whisper tends to force a transcription and hallucinate — while HAPPY / ANGRY / NEUTRAL emotions were detected. For example, one clip tagged ANGRY:

-
<|zh|><|ANGRY|><|Speech|><|withitn|>哎,不要看不起那些理想主义者,你脚下的每一步都是他们走出来的。
-

Full tag sets — emotion: HAPPY / SAD / ANGRY / NEUTRAL / FEARFUL / DISGUSTED / SURPRISED; events: Speech / BGM / Applause / Laughter / Cry.

- -

Parsing the tags

-
import re
-
-raw = res[0]["text"]
-tags = re.findall(r"<\|([^|]+)\|>", raw)
-language = tags[0] if tags else None                       # 'zh'
-emotion  = next((t for t in tags if t in
-            {"HAPPY","SAD","ANGRY","NEUTRAL","FEARFUL","DISGUSTED","SURPRISED"}), None)
-event    = next((t for t in tags if t in
-            {"Speech","BGM","Applause","Laughter","Cry"}), None)
-text     = re.sub(r"<\|[^|]+\|>", "", raw)         # clean text
-print(language, emotion, event, text)
- -

vs Whisper

- - - - - - - -
SenseVoiceWhisper
Transcript + language✅ one call
Emotion recognition✅ built-in❌ needs extra model
Audio events (BGM/applause/laughter)✅ built-in
Inverse text normalization✅ built-inpartial
Speednon-autoregressive, ~15× fasterautoregressive baseline
-

When you need understanding of audio and not just text, one SenseVoice model replaces a language-detector + emotion-model + event-classifier stack.

- -

The whole FunASR stack is open-source — industrial-grade ASR / VAD / punctuation / speaker / emotion & events / LLM-ASR. If it helps, a GitHub Star really supports the project 👇

⭐ Star FunASR

Also star:SenseVoice · Fun-ASR · FunClip

- -

Related posts

- -
- - - \ No newline at end of file +model = AutoModel( + model="iic/SenseVoiceSmall", + device="cpu", + disable_update=True, +) +raw = model.generate( + input="audio.wav", + language="auto", + use_itn=True, + ban_emo_unk=False, +)[0]["text"] + +print("raw:", raw) +print("display:", rich_transcription_postprocess(raw)) +

The following is a real readout from the official Chinese speech sample, using Linux CPU, FunASR 1.4.15, PyTorch/torchaudio 2.10.0 and cached SenseVoiceSmall weights. The audio is about 5.55 seconds long. This is a functional check, not an emotion-accuracy or speed benchmark.

+
Actual waveform of the public Chinese sample, with time and amplitude axes
The input waveform, not emotion intensity, model attention or a confidence curve.
+

Raw prediction

+
<|zh|><|NEUTRAL|><|Speech|><|withitn|>欢迎大家来体验达摩院推出的语音识别模型。
+

Display text

+
欢迎大家来体验达摩院推出的语音识别模型。
+

Here zh is the predicted language, NEUTRAL the emotion category, Speech the audio-event tag and withitn the text-normalization mode. None proves the prediction correct. A Speech tag does not guarantee the entire recording is free from music or noise. This sample has no human emotion ground truth, so it does not establish emotion accuracy.

+

Display text is not an evaluation label

+

rich_transcription_postprocess() makes output suitable for display; it does not promise plain text only. It may remove control tags or represent emotion and events with emoji. The next input is a constructed tag string, not another recording's prediction. Running the same postprocessor demonstrates the difference.

+
Input: <|zh|><|HAPPY|><|Speech|><|withitn|>今天放假。
+Display: 今天放假。😊
+

Splitting display text and treating a word position as an emotion label loses or misreads information. Keep raw output such as res[0]["text"], parse it under the task's rules and store the display version separately. Different segments may have different predictions. One recording-level tag is not a timestamped or per-speaker account of emotion.

+

Likewise, ban_emo_unk=True does not mean “more accurate.” The implementation masks the unknown-emotion token's decoding score. Disallowing unknown does not provide new evidence. The example explicitly preserves that option. Send unknown or unparseable results for inspection instead of silently turning them into NEUTRAL.

+

Fix the evaluation contract before quoting a score

+

The CASIA / RAVDESS community reproduction issue remains unresolved. This article does not reproduce the paper's scores or access or distribute either dataset. First fix the lawful dataset version, sample manifest, class mapping, weight hash, software versions and metric definitions.

+

The maintained SER evaluator reads six evaluation classes from raw output, normalizes fear/fearful and surprise/surprised, and fails on outputs without a supported emotion tag instead of silently dropping records. This six-class evaluation contract is not the complete model vocabulary or a universal parser for arbitrary applications.

+

It reports WA (accuracy across all records) and UA (mean recall across the classes present in the manifest). A synthetic counting example: predict neutral for nine neutral records and one angry record, and WA is 90% while UA is 50%. This checks the maintained metric function, not SenseVoice performance. One aggregate score can hide total failure on a minority class.

+

For an application, listen to a sample covering your languages, noise, devices and speaking styles. Record false positives and unknown results before adopting the auxiliary tag. For “who spoke when,” follow the speaker and time-coverage acceptance path; do not infer identity from emotion tags.

+

Next, open the pinned SenseVoice SER evaluation guide. Start with your lawful manifest and retain raw outputs and unsuccessful cases.

+ + diff --git a/web-pages/product-site/legacy/img/sensevoice-tags-waveform.png b/web-pages/product-site/legacy/img/sensevoice-tags-waveform.png new file mode 100644 index 000000000..944ccf32e Binary files /dev/null and b/web-pages/product-site/legacy/img/sensevoice-tags-waveform.png differ diff --git a/web-pages/product-site/tests/browser/sensevoice-tags-article.spec.ts b/web-pages/product-site/tests/browser/sensevoice-tags-article.spec.ts new file mode 100644 index 000000000..e5a7b2da0 --- /dev/null +++ b/web-pages/product-site/tests/browser/sensevoice-tags-article.spec.ts @@ -0,0 +1,41 @@ +import { expect, test } from '@playwright/test'; + +for (const prefix of ['', 'en/']) { + for (const width of [320, 390, 1440]) { + test(`SenseVoice tags ${prefix || 'zh'} at ${width}px`, async ({ page }, testInfo) => { + const errors: string[] = []; + page.on('pageerror', error => errors.push(String(error))); + await page.setViewportSize({ width, height: 1000 }); + await page.goto(`/${prefix}blog/explanations/`); + await page.locator(`a[data-blog-story][href="/${prefix}blog/sensevoice-emotion-language-detection.html"]`).click(); + const article = page.locator('article'); + await expect(article.locator('h1')).toBeVisible(); + await expect(article.locator('[data-editorial="boundary"]')).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath('opening.png') }); + await article.locator('figure').scrollIntoViewIfNeeded(); + expect(await article.locator('figure img').evaluate((image: HTMLImageElement) => image.complete && image.naturalWidth >= 1400)).toBeTruthy(); + const raw = article.locator('[data-example="raw-output"]'); + await raw.scrollIntoViewIfNeeded(); + await expect(raw).toContainText('<|zh|>'); + expect(await raw.evaluate(node => node.scrollWidth - node.clientWidth)).toBeLessThanOrEqual(1); + expect(await raw.evaluate(node => { + const text = [...node.childNodes].find(child => child.nodeType === Node.TEXT_NODE && child.textContent?.trim()); + const button = node.querySelector('button'); + if (!text || !button) return false; + const range = document.createRange(); + const start = text.textContent!.search(/\S/); + range.setStart(text, start); + range.setEnd(text, start + 1); + return range.getBoundingClientRect().top >= button.getBoundingClientRect().bottom + 2; + }), 'Copy control must not overlap the first output line').toBeTruthy(); + await expect(article.locator('[data-example="display-output"]')).not.toContainText('<|'); + await page.screenshot({ path: testInfo.outputPath('readout.png') }); + await expect(article.locator('[data-editorial="next-step"] a')).toHaveAttribute('href', /4482962437ce8ebd1f0ac5b6793d2f82d2e2955d/); + expect(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1); + const peer = prefix ? '' : 'en/'; + await page.locator(`.header-actions a[href="/${peer}blog/sensevoice-emotion-language-detection.html"]`).click(); + await expect(page).toHaveURL(new RegExp(`/${peer}blog/sensevoice-emotion-language-detection\\.html$`)); + expect(errors).toEqual([]); + }); + } +} diff --git a/web-pages/product-site/tests/test_sensevoice_tags_article.py b/web-pages/product-site/tests/test_sensevoice_tags_article.py new file mode 100644 index 000000000..27c6bb949 --- /dev/null +++ b/web-pages/product-site/tests/test_sensevoice_tags_article.py @@ -0,0 +1,85 @@ +"""The tags story preserves raw output and avoids unsupported benchmark claims.""" + +import ast +import json +from pathlib import Path +import sys + +from bs4 import BeautifulSoup +import pytest + +SITE = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(SITE)) +from build import build + +SLUG = "sensevoice-emotion-language-detection" +SOURCE = "4482962437ce8ebd1f0ac5b6793d2f82d2e2955d" + + +@pytest.fixture(scope="module", params=["source", "built"]) +def root(request, tmp_path_factory): + if request.param == "source": + return SITE / "legacy" + output = tmp_path_factory.mktemp("tags-story") + build(output) + return output + + +@pytest.mark.parametrize("prefix", ["", "en/"]) +def test_raw_prediction_and_display_are_separate(root, prefix): + soup = BeautifulSoup((root / prefix / "blog" / f"{SLUG}.html").read_text(), "html.parser") + article = soup.select_one("article") + assert article.select_one('[data-editorial="opening"]') + assert article.select_one('[data-editorial="boundary"]') + raw = article.select_one('[data-example="raw-output"]') + display = article.select_one('[data-example="display-output"]') + assert raw is not None and display is not None + assert "<|zh|>" in raw.get_text() and "<|" not in display.get_text() + assert article.select_one('[data-example="synthetic-display"]') + assert len(article.select('[data-editorial="next-step"] a')) == 1 + assert article.select_one(f'a[href*="/{SOURCE}/benchmarks/ser/README.md"]') + assert article.select_one('a[href*="/issues/212"]') + assert article.select_one('a[data-installation-guide]') + assert "PyTorch" in article.select_one('[data-editorial="environment"]').get_text() + assert "ban_emo_unk" in article.get_text() + assert not any(text in article.get_text() for text in ["15×", "15x", "56 out of 60", "56 段被正确"]) + assert len(article.select("h1")) == 1 + assert len(article.select("h2")) <= 5 + assert soup.select_one('link[rel="canonical"]')["href"] == f"https://www.funasr.com/{prefix}blog/{SLUG}.html" + metadata = json.loads(soup.select_one('script[type="application/ld+json"]').get_text()) + assert metadata["datePublished"] == "2026-06-19" + assert metadata["dateModified"] == "2026-09-10" + assert metadata["headline"] == article.h1.get_text() + image = article.select_one("figure img") + assert image is not None and image.get("alt") + assert (root / image["src"].lstrip("/")).is_file() + + +@pytest.mark.parametrize("prefix", ["", "en/"]) +def test_example_keeps_unknown_predictions_and_uses_public_sdk(prefix): + soup = BeautifulSoup((SITE / "legacy" / prefix / "blog" / f"{SLUG}.html").read_text(), "html.parser") + code = soup.select_one('pre[data-example="recognize"]') + assert code is not None + tree = ast.parse(code.get_text()) + calls = [n for n in ast.walk(tree) if isinstance(n, ast.Call)] + model = next(n for n in calls if isinstance(n.func, ast.Name) and n.func.id == "AutoModel") + assert {k.arg: ast.literal_eval(k.value) for k in model.keywords} == { + "model": "iic/SenseVoiceSmall", "device": "cpu", "disable_update": True, + } + generate = next(n for n in calls if isinstance(n.func, ast.Attribute) and n.func.attr == "generate") + options = {k.arg: ast.literal_eval(k.value) for k in generate.keywords} + assert options == {"input": "audio.wav", "language": "auto", "use_itn": True, "ban_emo_unk": False} + assert any(isinstance(n.func, ast.Name) and n.func.id == "rich_transcription_postprocess" for n in calls) + assert not any(isinstance(n.func, ast.Attribute) and n.func.attr in {"findall", "split"} for n in calls) + + +def test_story_is_reviewed_without_growing_the_homepage(): + data = json.loads((SITE / "data/blog.json").read_text()) + row = next(entry for entry in data["articles"] if entry["slug"] == SLUG) + assert row["reviewed"] and row["category"] == "explanations" + assert row["zh"]["summary"] and row["en"]["summary"] + assert data["lead"] == "funclip-v2-2-0-moss-speaker-clipping" + assert data["selected"] == [ + "meeting-transcript-acceptance", "fun-asr-nano-transformers", + "self-hosted-openai-whisper-api-alternative", "funasr-transcribe-long-audio", + ]