Skip to content

Commit d431003

Browse files
authored
Fix CLI SRT sentence segmentation (#3254)
1 parent 98536ea commit d431003

2 files changed

Lines changed: 133 additions & 8 deletions

File tree

funasr/cli.py

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,12 @@ def clean_text(text):
2020

2121

2222
def _srt_time(ms):
23-
s = ms / 1000.0
24-
h, m, sec = int(s // 3600), int((s % 3600) // 60), int(s % 60)
25-
return f"{h:02d}:{m:02d}:{sec:02d},{int((s % 1) * 1000):03d}"
23+
ms = max(0, int(round(ms)))
24+
h = ms // 3600000
25+
m = (ms % 3600000) // 60000
26+
sec = (ms % 60000) // 1000
27+
ms_rem = ms % 1000
28+
return f"{h:02d}:{m:02d}:{sec:02d},{ms_rem:03d}"
2629

2730

2831
def format_srt(segments):
@@ -39,6 +42,38 @@ def format_tsv(segments):
3942
return "\n".join(lines)
4043

4144

45+
def _parse_ms(value, scale=1):
46+
if value is None:
47+
return None
48+
try:
49+
return int(float(value) * scale)
50+
except (TypeError, ValueError):
51+
return None
52+
53+
54+
def _timestamp_bounds_ms(result):
55+
bounds = []
56+
for key in ("timestamp", "timestamps"):
57+
for ts in result.get(key, []) or []:
58+
if isinstance(ts, dict):
59+
start = ts.get("start_time", ts.get("start"))
60+
end = ts.get("end_time", ts.get("end"))
61+
start_ms = _parse_ms(start, 1000)
62+
end_ms = _parse_ms(end, 1000)
63+
elif isinstance(ts, (list, tuple)) and len(ts) >= 2:
64+
start_ms = _parse_ms(ts[0])
65+
end_ms = _parse_ms(ts[1])
66+
else:
67+
continue
68+
if start_ms is None or end_ms is None:
69+
continue
70+
if end_ms > start_ms:
71+
bounds.append((start_ms, end_ms))
72+
if not bounds:
73+
return None
74+
return min(start for start, _ in bounds), max(end for _, end in bounds)
75+
76+
4277
def _format_output(text, segments, timestamps, fmt, audio_path, model_name, language, elapsed):
4378
if fmt == "text":
4479
return text
@@ -58,8 +93,12 @@ def _format_output(text, segments, timestamps, fmt, audio_path, model_name, lang
5893
elif fmt == "srt":
5994
if segments:
6095
return format_srt(segments)
61-
# No per-sentence timestamps: emit one valid cue spanning the whole audio
62-
# (instead of a bogus 99:59:59 end time).
96+
# No per-sentence timestamps: emit one valid cue spanning the known
97+
# timestamp/audio bounds instead of a bogus 99:59:59 end time.
98+
timestamp_bounds = _timestamp_bounds_ms({"timestamp": timestamps})
99+
if timestamp_bounds:
100+
start_ms, end_ms = timestamp_bounds
101+
return f"1\n{_srt_time(start_ms)} --> {_srt_time(end_ms)}\n{text}\n"
63102
try:
64103
import soundfile as sf
65104
dur_ms = int(sf.info(audio_path).duration * 1000)
@@ -115,8 +154,9 @@ def main():
115154
config["hub"] = args.hub
116155
if args.spk and "spk_model" not in config:
117156
config["spk_model"] = "cam++"
118-
if "punc_model" not in config and args.model not in ("fun-asr-nano", "sensevoice"):
119-
config["punc_model"] = "ct-punc"
157+
if "punc_model" not in config and args.model != "fun-asr-nano":
158+
if args.model != "sensevoice" or args.output_format in ("srt", "tsv"):
159+
config["punc_model"] = "ct-punc"
120160

121161
t_load = time.time()
122162
model = AutoModel(device=device, disable_update=True, **config)
@@ -147,6 +187,15 @@ def main():
147187
else:
148188
gen_kw["hotwords"] = hotwords
149189

190+
if args.output_format in ("srt", "tsv"):
191+
gen_kw.update(
192+
{
193+
"sentence_timestamp": True,
194+
"output_timestamp": True,
195+
"return_time_stamps": True,
196+
}
197+
)
198+
150199
result = model.generate(**gen_kw)
151200
elapsed = time.time() - t0
152201

@@ -159,7 +208,9 @@ def main():
159208
s["speaker"] = seg["spk"]
160209
segments.append(s)
161210

162-
timestamps = result[0].get("timestamps") if args.timestamps else None
211+
timestamps = result[0].get("timestamps") or result[0].get("timestamp")
212+
if not args.timestamps and args.output_format not in ("srt", "tsv"):
213+
timestamps = None
163214
output = _format_output(text, segments, timestamps, args.output_format, audio_path, args.model, args.language, elapsed)
164215

165216
if args.output_dir:

tests/test_cli.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,22 @@ def generate(self, **kwargs):
1919
return [{"text": "hello"}]
2020

2121

22+
class SubtitleAutoModel(DummyAutoModel):
23+
def generate(self, **kwargs):
24+
self.generate_kwargs = kwargs
25+
if kwargs.get("sentence_timestamp") and kwargs.get("output_timestamp"):
26+
return [
27+
{
28+
"text": "<|zh|>第一句。第二句。",
29+
"sentence_info": [
30+
{"start": 0, "end": 1200, "text": "<|zh|>第一句。"},
31+
{"start": 1200, "end": 2600, "sentence": "第二句。"},
32+
],
33+
}
34+
]
35+
return [{"text": "<|zh|>第一句。第二句。"}]
36+
37+
2238
def test_cli_passes_hub_to_auto_model(tmp_path):
2339
audio_path = tmp_path / "sample.wav"
2440
audio_path.write_bytes(b"not a real wav")
@@ -69,3 +85,61 @@ def test_cli_routes_multiple_hotwords_to_paraformer_hotword(tmp_path):
6985
generate_kwargs = DummyAutoModel.instances[0].generate_kwargs
7086
assert generate_kwargs["hotword"] == "FunASR ModelScope"
7187
assert "hotwords" not in generate_kwargs
88+
89+
90+
def test_timestamp_bounds_skip_malformed_entries():
91+
assert cli._timestamp_bounds_ms(
92+
{
93+
"timestamp": [
94+
[None, 1000],
95+
["bad", "2000"],
96+
["1200.0", "2600.0"],
97+
{"start": "0.5", "end": "1.0"},
98+
{"start_time": "oops", "end_time": "2.0"},
99+
]
100+
}
101+
) == (500, 2600)
102+
103+
104+
def test_cli_srt_requests_sentence_timestamps_and_writes_segmented_output(tmp_path):
105+
audio_path = tmp_path / "sample.wav"
106+
audio_path.write_bytes(b"not a real wav")
107+
out_dir = tmp_path / "subs"
108+
fake_torch = types.SimpleNamespace(
109+
cuda=types.SimpleNamespace(is_available=lambda: False),
110+
)
111+
112+
SubtitleAutoModel.instances = []
113+
argv = [
114+
"funasr",
115+
str(audio_path),
116+
"--output-format",
117+
"srt",
118+
"--output-dir",
119+
str(out_dir),
120+
"--lang",
121+
"zh",
122+
]
123+
124+
with (
125+
patch.object(sys, "argv", argv),
126+
patch.dict(sys.modules, {"torch": fake_torch}),
127+
patch("funasr.AutoModel", SubtitleAutoModel),
128+
redirect_stdout(io.StringIO()),
129+
):
130+
cli.main()
131+
132+
instance = SubtitleAutoModel.instances[0]
133+
assert instance.kwargs["punc_model"] == "ct-punc"
134+
assert instance.generate_kwargs["language"] == "zh"
135+
assert instance.generate_kwargs["sentence_timestamp"] is True
136+
assert instance.generate_kwargs["output_timestamp"] is True
137+
assert instance.generate_kwargs["return_time_stamps"] is True
138+
assert (out_dir / "sample.srt").read_text(encoding="utf-8") == (
139+
"1\n"
140+
"00:00:00,000 --> 00:00:01,200\n"
141+
"第一句。\n\n"
142+
"2\n"
143+
"00:00:01,200 --> 00:00:02,600\n"
144+
"第二句。\n"
145+
)

0 commit comments

Comments
 (0)