← Back to list

Transcription That Also Hears Emotion: A Local Speech-Understanding API with SenseVoice and FastAPI

Most speech-to-text pipelines throw the interesting part away. You get the words, and everything else — was the speaker angry, was there…

Varun · 2026-06-08 09:06 · 0 claps · 3.7 min read
#llm #ai #speech-recognition
Open on Medium ↗
Wiki topics: LLM · Large Language Models AI · AI · General

Transcription That Also Hears Emotion: A Local Speech-Understanding API with SenseVoice and FastAPI

Most speech-to-text pipelines throw the interesting part away. You get the words, and everything else — was the speaker angry, was there laughter on the line — is gone. The usual fix is to bolt a second model onto the first: one for transcription, another for sentiment. That is two models to load and reconcile.

The objective of this exercise was to get the words, the emotion, and the audio events out of a single pass. SenseVoice does exactly that: automatic speech recognition, speech emotion recognition. We wrapped it in a FastAPI endpoint that takes an audio file and returns structured JSON, running locally on Apple Silicon — no API keys, no audio leaving the machine. Below are the steps and the code.

Step 1 — Virtual environment

Seven dependencies, listed in requirements.txt. funasr pulls the model framework; the iic/SenseVoiceSmall weights download from ModelScope on first run, so there is no separate download step. python-multipart is what lets FastAPI accept the file upload. fastapi uvicorn funasr modelscope torch torchaudio python-multipart

import os
import re
import shutil
import tempfile
from fastapi import FastAPI, UploadFile, File, HTTPException
from funasr import AutoModel

Step 2 — Load the model once

The model is loaded outside the route, at module import, so it initializes a single time when the server boots rather than on every request. SenseVoiceSmall is small enough to sit in memory comfortably.

import os
import re
import shutil
import tempfile
from fastapi import FastAPI, UploadFile, File, HTTPException
from funasr import AutoModel

app = FastAPI(title="End-to-End Emotion Detection API")

# --- Model Initialization ---
# We load the model outside the route so it only initializes once on startup.
print("Loading SenseVoice model into memory...")
model = AutoModel(
    model="iic/SenseVoiceSmall",
    trust_remote_code=True,
    device="cpu"  # For Apple Silicon, "cpu" is highly stable out of the box. You can experiment with "mps" for GPU acceleration.
)
print("Model ready.")

Step 3 — The API

The whole service is one file. A POST endpoint validates the file type, writes the upload to a temp file (the model reads from a path, not a buffer), runs inference, parses the result, and cleans up the temp file in a finally block so nothing leaks to disk.

import os
import re
import shutil
import tempfile
from fastapi import FastAPI, UploadFile, File, HTTPException
from funasr import AutoModel

app = FastAPI(title="End-to-End Emotion Detection API")

# --- Model Initialization ---
# We load the model outside the route so it only initializes once on startup.
print("Loading SenseVoice model into memory...")
model = AutoModel(
    model="iic/SenseVoiceSmall",
    trust_remote_code=True,
    device="cpu"  # For Apple Silicon, "cpu" is highly stable out of the box. You can experiment with "mps" for GPU acceleration.
)
print("Model ready.")

@app.post("/detect-emotion")
async def detect_emotion(audio_file: UploadFile = File(...)):
    if not audio_file.filename.endswith(('.wav', '.mp3', '.m4a')):
        raise HTTPException(status_code=400, detail="Invalid file type. Please upload an audio file (.wav, .mp3, .m4a).")

    # 1. Save the uploaded file to disk temporarily for the model to read
    with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as temp_audio:
        shutil.copyfileobj(audio_file.file, temp_audio)
        temp_file_path = temp_audio.name

    try:
        # 2. Run Inference
        result = model.generate(
            input=temp_file_path,
            cache={},
            language="auto",
            use_itn=True
        )

        raw_output = result[0]["text"]

        # 3. Parse the Rich Transcription
        emotion_match = re.search(r'<\|(HAPPY|SAD|ANGRY|NEUTRAL|SURPRISED|FEAR)\|>', raw_output, re.IGNORECASE)
        emotion = emotion_match.group(1).upper() if emotion_match else "NEUTRAL"

        # Parse audio events
        event_match = re.findall(r'<\|(Laughter|Applause|Music|Cough|Sneeze|Crying)\|>', raw_output, re.IGNORECASE)
        events = [e.capitalize() for e in event_match] if event_match else []

        # Remove all the bracketed tags to get just the spoken text
        clean_transcript = re.sub(r'<\|.*?\|>', '', raw_output).strip()

        # 4. Return the structured JSON payload
        return {
            "status": "success",
            "emotion": emotion,
            "events": events,
            "transcript": clean_transcript,
            "raw_output": raw_output
        }

    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

    finally:
        # 5. Cleanup the temporary file to prevent storage leaks
        if os.path.exists(temp_file_path):
            os.remove(temp_file_path)

Step 4 — Parsing the rich transcription

SenseVoice does not hand back tidy fields. It returns one string with everything encoded as inline tags at the front:

<|en|><|ANGRY|><|Speech|>I have called three times about this charge and nobody has fixed it.

So the parsing is three small regexes against that string. Pull the emotion tag, defaulting to NEUTRAL when none is present. Collect any audio-event tags — laughter, applause, music, cough, sneeze, crying.

The raw output goes back in the payload too, so nothing the model said is lost if you want to parse more later.

Two inference flags shape the text: language=”auto” lets the model detect the language, and use_itn=True turns on inverse text normalization, so numbers and punctuation come out readable

Step 5 — Run it

Start the server with uvicorn, then send it an audio file with curl. The repo includes a customer_call.wav to try it against.

# Install and launch the API 
pip install -r requirements.txt 
uvicorn main:app -- host 0.0.0.0 -- port 8000 

# In another terminal, send it an audio file 
curl -X POST "http://127.0.0.1:8000/detect-emotion" \ -F "audio_file=@customer_call.wav"

The response is as below:

curl -s -X POST "http://127.0.0.1:8000/detect-emotion" -H "accept: application/json" -H "Content-Type: multipart/form-data" -F "audio_file=@/Users/vk/Downloads/anger.wav"
{"status":"success","emotion":"ANGRY","events":[],"transcript":"What can you prove it, what proof do you have, can you prove it?","raw_output":"<|en|><|ANGRY|><|Speech|><|withitn|>What can you prove it, what proof do you have, can you prove it?"}%                                                                               vk@Varuns-Air trascribe_with_emotions %

Outcome

One model, one inference pass, three signals: the transcript, the speaker’s emotion, and any audio events — behind a single endpoint that any other tool can POST a file to.

The split that usually costs you two models collapses into about sixty lines and one set of tags to parse.

The next steps for the experiment are the obvious ones: try the “mps” device, batch multiple files per request, and pull SenseVoice’s timestamp output through so emotion can be tracked across a long call rather than summarized into a single label.

The emotion label is a coarse, whole-clip read — useful for triage, not a substitute for listening.

As always, create a separate virtual environment for testing.


메타데이터
post_id
7c5d25527dc0
slug
transcription-that-also-hears-emotion-a-local-speech-understanding-api-with-sensevoice-and-fastapi-7c5d25527dc0
url
https://medium.com/@data314/transcription-that-also-hears-emotion-a-local-speech-understanding-api-with-sensevoice-and-fastapi-7c5d25527dc0
canonical_url
https://medium.com/@data314/transcription-that-also-hears-emotion-a-local-speech-understanding-api-with-sensevoice-and-fastapi-7c5d25527dc0
author_url
https://medium.com/@data314
status
ok
fetched_at
2026-06-09 21:21:26