Skip to content

Commit 6be08b4

Browse files
authored
fix(server): segment text-only OpenAI transcription responses (#3292)
1 parent a2a6ba3 commit 6be08b4

2 files changed

Lines changed: 126 additions & 1 deletion

File tree

funasr/bin/_server_app.py

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,67 @@
2626
logger = logging.getLogger("funasr.server")
2727

2828

29+
def _split_text_for_openai_segments(text: str, max_chars: int = 80):
30+
"""Split unsegmented ASR text into readable OpenAI-compatible cues."""
31+
text = text.strip()
32+
if not text:
33+
return []
34+
35+
words = text.split()
36+
if not words:
37+
return [text[i : i + max_chars] for i in range(0, len(text), max_chars)]
38+
39+
parts = []
40+
current = []
41+
current_len = 0
42+
for word in words:
43+
next_len = len(word) if not current else current_len + 1 + len(word)
44+
if current and next_len > max_chars:
45+
parts.append(" ".join(current))
46+
current = []
47+
current_len = 0
48+
49+
current.append(word)
50+
current_len = len(word) if current_len == 0 else current_len + 1 + len(word)
51+
if word[-1:] in ".!?;:" and current_len >= max_chars // 2:
52+
parts.append(" ".join(current))
53+
current = []
54+
current_len = 0
55+
56+
if current:
57+
parts.append(" ".join(current))
58+
59+
if any(len(part) > max_chars for part in parts):
60+
return [text[i : i + max_chars] for i in range(0, len(text), max_chars)]
61+
62+
return parts
63+
64+
65+
def build_openai_fallback_segments(text: str, duration: float, max_chars: int = 80):
66+
"""Build coarse timestamped segments when a backend returns text only."""
67+
parts = _split_text_for_openai_segments(text, max_chars=max_chars)
68+
if not parts:
69+
return []
70+
if len(parts) == 1 or duration <= 0:
71+
return [{"start": 0.0, "end": max(float(duration), 0.0), "text": parts[0]}]
72+
73+
total_chars = sum(len(part) for part in parts)
74+
if total_chars <= 0:
75+
return [{"start": 0.0, "end": float(duration), "text": text.strip()}]
76+
77+
segments = []
78+
consumed = 0
79+
previous_end = 0.0
80+
for i, part in enumerate(parts):
81+
consumed += len(part)
82+
end = float(duration) if i == len(parts) - 1 else float(duration) * consumed / total_chars
83+
end = max(end, previous_end)
84+
segments.append({"start": round(previous_end, 3), "end": round(end, 3), "text": part})
85+
previous_end = end
86+
87+
return segments
88+
89+
2990
def prepare_audio_for_inference(audio_data, sr, target_sr=16000):
3091
"""Return mono float32 audio at target_sr for ASR inference."""
3192
audio_data = np.asarray(audio_data)
@@ -174,6 +235,10 @@ def _process_vllm(audio_data, sr, language=None, hotwords=None, use_spk=False):
174235
def _process_fallback(model_name, audio_path, language=None):
175236
"""Process with non-LLM model (SenseVoice/Paraformer)."""
176237
model = _load_fallback(model_name)
238+
try:
239+
duration = float(sf.info(audio_path).duration)
240+
except Exception:
241+
duration = 0.0
177242
kwargs = {"input": audio_path, "batch_size": 1}
178243
if language:
179244
kwargs["language"] = language
@@ -188,7 +253,9 @@ def _process_fallback(model_name, audio_path, language=None):
188253
"text": re.sub(r'<\|[^|]*\|>', '', s.get("text", "")).strip(),
189254
"speaker": s.get("spk"),
190255
})
191-
return {"text": text, "segments": segments}
256+
if not segments and text:
257+
segments = build_openai_fallback_segments(text, duration)
258+
return {"text": text, "segments": segments, "duration": duration}
192259

193260
# Pre-load
194261
if preload_model == "fun-asr-nano":
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import importlib.util
2+
import sys
3+
import types
4+
from pathlib import Path
5+
6+
7+
REPO_ROOT = Path(__file__).resolve().parents[1]
8+
SERVICE_PATH = REPO_ROOT / "funasr" / "bin" / "_server_app.py"
9+
10+
11+
def load_server_app(monkeypatch):
12+
fastapi_stub = types.ModuleType("fastapi")
13+
fastapi_stub.FastAPI = object
14+
fastapi_stub.UploadFile = object
15+
fastapi_stub.File = lambda *args, **kwargs: None
16+
fastapi_stub.Form = lambda *args, **kwargs: None
17+
fastapi_stub.HTTPException = Exception
18+
19+
responses_stub = types.ModuleType("fastapi.responses")
20+
responses_stub.JSONResponse = lambda content=None: content
21+
22+
monkeypatch.setitem(sys.modules, "fastapi", fastapi_stub)
23+
monkeypatch.setitem(sys.modules, "fastapi.responses", responses_stub)
24+
25+
module_name = "funasr_server_app_under_test"
26+
sys.modules.pop(module_name, None)
27+
spec = importlib.util.spec_from_file_location(module_name, SERVICE_PATH)
28+
module = importlib.util.module_from_spec(spec)
29+
assert spec.loader is not None
30+
spec.loader.exec_module(module)
31+
return module
32+
33+
34+
def test_fallback_segments_split_long_fun_asr_server_text(monkeypatch):
35+
module = load_server_app(monkeypatch)
36+
text = (
37+
"i believe that this nation should commit itself to achieving the goal before this decade is out "
38+
"of landing a man on the moon and returning him safely to the earth "
39+
"no single space project in this period will be more impressive to mankind "
40+
"or more important for the long range exploration of space"
41+
)
42+
43+
segments = module.build_openai_fallback_segments(text, duration=21.0)
44+
45+
assert len(segments) > 1
46+
assert segments[0]["start"] == 0.0
47+
assert segments[-1]["end"] == 21.0
48+
assert all(segment["end"] >= segment["start"] for segment in segments)
49+
assert all(len(segment["text"]) <= 80 for segment in segments)
50+
assert " ".join(segment["text"] for segment in segments) == text
51+
52+
53+
def test_fallback_segments_keep_short_text_single_cue(monkeypatch):
54+
module = load_server_app(monkeypatch)
55+
56+
assert module.build_openai_fallback_segments("hello", duration=1.25) == [
57+
{"start": 0.0, "end": 1.25, "text": "hello"}
58+
]

0 commit comments

Comments
 (0)