<|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|>呢几个字都表达唔到,我想讲嘅意思。
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)
+
+
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)
-
2026-06-19 · FunASR Team
-
-
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
-
-
Capability
Detail
-
ASR
Mandarin, Cantonese, English, Japanese, and Korean, leading Chinese accuracy
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:
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
-
-
SenseVoice
Whisper
-
Transcript + language
✅ one call
✅
-
Emotion recognition
✅ built-in
❌ needs extra model
-
Audio events (BGM/applause/laughter)
✅ built-in
❌
-
Inverse text normalization
✅ built-in
partial
-
Speed
non-autoregressive, ~15× faster
autoregressive 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 👇
-
-
-
\ 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.
+The input waveform, not emotion intensity, model attention or a confidence curve.
+
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.
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",
+ ]