feat(cli): group readable subtitle cues

Signed-off-by: LauraGPT <[email protected]>
This commit is contained in:
LauraGPT
2026-08-28 14:17:41 +00:00
parent c5ac6495f7
commit d5e0eeefd1
6 changed files with 263 additions and 1 deletions
+8 -1
View File
@@ -27,7 +27,9 @@ funasr audio.wav --output-format srt --output-dir ./subs
`srt` and `tsv` outputs request sentence-level timestamps. In FunASR 1.3.18
and newer, the default `sensevoice` CLI path also loads punctuation for subtitle
generation, so subtitle files are split into sentence cues instead of one
full-text block when the model returns `sentence_info`.
full-text block when the model returns `sentence_info`. SRT output groups short
or continuation cues into bounded, readable subtitles by default. Use
`--subtitle-segment-mode sentence` to preserve the model's raw sentence boundaries.
## Options
@@ -38,6 +40,7 @@ full-text block when the model returns `sentence_info`.
| `--language` | `-l` | auto | Language: zh, en, ja, ko, yue, auto |
| `--device` | | auto | Device: cuda:0, cpu |
| `--output-format` | `-f` | text | Output: text, json, srt, tsv |
| `--subtitle-segment-mode` | | readable | SRT grouping: readable or raw sentence boundaries |
| `--output-dir` | `-o` | stdout | Write output files to directory |
| `--timestamps` | | off | Include word-level timestamps |
| `--spk` | | off | Enable speaker diarization |
@@ -82,6 +85,10 @@ SubRip subtitle format:
If a model does not return sentence-level timestamps, the CLI falls back to one
valid cue spanning the known timestamp or audio duration.
Readable mode only joins adjacent cues when the gap is at most 500 ms, the
combined cue is at most 8 seconds and 42 characters, and the speaker is unchanged.
It preserves recognized text and punctuation. JSON and TSV segments are unchanged.
### tsv
Tab-separated values (start/end in seconds):
```
+8
View File
@@ -19,6 +19,9 @@ python generate_subtitle.py audio.wav --model paraformer-zh
# CPU mode
python generate_subtitle.py audio.wav --device cpu
# Preserve raw model sentence boundaries
python generate_subtitle.py audio.wav --segment-mode sentence
```
## Output Example (SRT)
@@ -38,12 +41,17 @@ python generate_subtitle.py audio.wav --device cpu
| Option | Default | Description |
|--------|---------|-------------|
| `--format` | srt | Output format: srt or vtt |
| `--segment-mode` | readable | Cue grouping: readable or raw sentence boundaries |
| `--model` | SenseVoiceSmall | ASR model |
| `--device` | cuda | Device: cuda or cpu |
| `--spk` | off | Add speaker labels |
| `--lang` | auto | Language hint |
| `-o` | input.srt | Output path |
Readable mode joins only adjacent short or continuation cues within bounded
gap, duration, length, and speaker limits. It does not rewrite recognized text
or punctuation.
## Install
```bash
+11
View File
@@ -14,6 +14,8 @@ import sys
import os
import re
from funasr.cli import merge_subtitle_segments
def clean_text(text):
return re.sub(r'<\|[^|]*\|>', '', text or "").strip()
@@ -63,6 +65,12 @@ def main():
parser.add_argument("input", help="Audio/video file path")
parser.add_argument("-o", "--output", help="Output file (default: input.srt)")
parser.add_argument("--format", choices=["srt", "vtt"], default="srt")
parser.add_argument(
"--segment-mode",
choices=["readable", "sentence"],
default="readable",
help="Cue grouping: readable (default) or raw model sentence boundaries",
)
parser.add_argument("--model", default="iic/SenseVoiceSmall")
parser.add_argument("--device", default="cuda")
parser.add_argument("--spk", action="store_true", help="Include speaker labels")
@@ -121,6 +129,9 @@ def main():
print("No speech detected.")
sys.exit(0)
if args.segment_mode == "readable":
segments = merge_subtitle_segments(segments)
fmt = format_time_srt if args.format == "srt" else format_time_vtt
with open(output_path, "w", encoding="utf-8") as f:
if args.format == "vtt":
+108
View File
@@ -14,6 +14,24 @@ MODEL_CONFIGS = {
"fun-asr-nano": {"model": "FunAudioLLM/Fun-ASR-Nano-2512", "vad_model": "fsmn-vad"},
}
SUBTITLE_CONTINUATION_PUNCTUATION = (
",",
",",
"、",
":",
":",
";",
";",
)
SUBTITLE_TRAILING_PUNCTUATION = SUBTITLE_CONTINUATION_PUNCTUATION + (
"。",
".",
"!",
"!",
"?",
"?",
)
def clean_text(text):
return re.sub(r"<\|[^|]*\|>", "", text).strip()
@@ -35,6 +53,85 @@ def format_srt(segments):
return "\n".join(lines)
def _subtitle_body_length(text):
return len(
str(text).strip().rstrip("".join(SUBTITLE_TRAILING_PUNCTUATION)).strip()
)
def _join_subtitle_text(left, right):
left = str(left).rstrip()
right = str(right).lstrip()
if (
left
and right
and left[-1].isascii()
and right[0].isascii()
and left[-1].isalnum()
and right[0].isalnum()
):
return f"{left} {right}"
return left + right
def merge_subtitle_segments(
segments, max_gap_ms=500, max_duration_ms=8000, max_chars=42
):
"""Group sentence timestamps into bounded, readable subtitle cues."""
def can_follow(left, right):
left_text = str(left.get("text", ""))
gap_ms = right.get("start", 0) - left.get("end", 0)
left_speaker = left.get("speaker", left.get("spk"))
right_speaker = right.get("speaker", right.get("spk"))
return (
left_speaker == right_speaker
and 0 <= gap_ms <= max_gap_ms
and (
left_text.rstrip().endswith(SUBTITLE_CONTINUATION_PUNCTUATION)
or _subtitle_body_length(left_text) <= 2
)
)
def combine(group):
cue = dict(group[0])
cue["end"] = group[-1].get("end", cue.get("end", 0))
text = str(group[0].get("text", ""))
for item in group[1:]:
text = _join_subtitle_text(text, item.get("text", ""))
cue["text"] = text
return cue
def pack(chain):
groups = []
current = [chain[-1]]
for item in reversed(chain[:-1]):
candidate = [item, *current]
combined = combine(candidate)
duration_ms = combined.get("end", 0) - combined.get("start", 0)
if (
duration_ms <= max_duration_ms
and len(combined.get("text", "")) <= max_chars
):
current = candidate
else:
groups.append(current)
current = [item]
groups.append(current)
return [combine(group) for group in reversed(groups)]
merged = []
chain = []
for source in segments:
current = dict(source)
if chain and not can_follow(chain[-1], current):
merged.extend(pack(chain))
chain = []
chain.append(current)
if chain:
merged.extend(pack(chain))
return merged
def format_tsv(segments):
lines = ["start\tend\ttext"]
for seg in segments:
@@ -135,6 +232,12 @@ def main():
p.add_argument("--language", "-l", default=None, help="Language: zh, en, ja, ko, yue, auto")
p.add_argument("--device", default=None, help="Device: cuda:0, cpu (default: auto)")
p.add_argument("--output-format", "-f", default="text", choices=["text", "json", "srt", "tsv"], help="Output format (default: text)")
p.add_argument(
"--subtitle-segment-mode",
choices=["readable", "sentence"],
default="readable",
help="SRT cue grouping: readable (default) or raw model sentence boundaries",
)
p.add_argument("--output-dir", "-o", default=None, help="Write output files to directory")
p.add_argument("--timestamps", action="store_true", help="Include word-level timestamps")
p.add_argument("--spk", action="store_true", help="Enable speaker diarization")
@@ -207,6 +310,11 @@ def main():
if args.spk and "spk" in seg:
s["speaker"] = seg["spk"]
segments.append(s)
if (
args.output_format == "srt"
and args.subtitle_segment_mode == "readable"
):
segments = merge_subtitle_segments(segments)
timestamps = result[0].get("timestamps") or result[0].get("timestamp")
if not args.timestamps and args.output_format not in ("srt", "tsv"):
+121
View File
@@ -35,6 +35,20 @@ class SubtitleAutoModel(DummyAutoModel):
return [{"text": "<|zh|>第一句。第二句。"}]
class ContinuationSubtitleAutoModel(DummyAutoModel):
def generate(self, **kwargs):
self.generate_kwargs = kwargs
return [
{
"text": "甲,乙。",
"sentence_info": [
{"start": 0, "end": 500, "text": "甲,"},
{"start": 600, "end": 1200, "text": "乙。"},
],
}
]
def test_cli_passes_hub_to_auto_model(tmp_path):
audio_path = tmp_path / "sample.wav"
audio_path.write_bytes(b"not a real wav")
@@ -143,3 +157,110 @@ def test_cli_srt_requests_sentence_timestamps_and_writes_segmented_output(tmp_pa
"00:00:01,200 --> 00:00:02,600\n"
"第二句。\n"
)
def test_cli_srt_supports_readable_and_sentence_segment_modes(tmp_path):
audio_path = tmp_path / "sample.wav"
audio_path.write_bytes(b"not a real wav")
fake_torch = types.SimpleNamespace(
cuda=types.SimpleNamespace(is_available=lambda: False)
)
outputs = {}
for mode in ("readable", "sentence"):
out_dir = tmp_path / mode
argv = [
"funasr",
str(audio_path),
"--output-format",
"srt",
"--output-dir",
str(out_dir),
]
if mode == "sentence":
argv.extend(["--subtitle-segment-mode", "sentence"])
with (
patch.object(sys, "argv", argv),
patch.dict(sys.modules, {"torch": fake_torch}),
patch("funasr.AutoModel", ContinuationSubtitleAutoModel),
redirect_stdout(io.StringIO()),
):
cli.main()
outputs[mode] = (out_dir / "sample.srt").read_text(encoding="utf-8")
assert outputs["readable"] == "1\n00:00:00,000 --> 00:00:01,200\n甲,乙。\n"
assert outputs["sentence"] == (
"1\n00:00:00,000 --> 00:00:00,500\n甲,\n\n"
"2\n00:00:00,600 --> 00:00:01,200\n乙。\n"
)
def test_merge_subtitle_segments_groups_continuation_cues():
segments = [
{"start": 98520, "end": 99900, "text": "救护车上常用的呼吸机、"},
{"start": 100140, "end": 100500, "text": "除颤仪,"},
{"start": 100620, "end": 101580, "text": "属于二类医疗器械。"},
]
assert cli.merge_subtitle_segments(segments) == [
{
"start": 98520,
"end": 101580,
"text": "救护车上常用的呼吸机、除颤仪,属于二类医疗器械。",
}
]
def test_merge_subtitle_segments_keeps_continuation_chain_with_its_ending():
segments = [
{
"start": 93000,
"end": 95500,
"text": "而君逸公司连医疗器械经营资质都不具备,",
},
{"start": 95700, "end": 99900, "text": "救护车上常用的呼吸机、"},
{"start": 100140, "end": 100500, "text": "除颤仪,"},
{"start": 100620, "end": 101580, "text": "属于二类医疗器械。"},
]
assert cli.merge_subtitle_segments(segments) == [
{
"start": 93000,
"end": 95500,
"text": "而君逸公司连医疗器械经营资质都不具备,",
},
{
"start": 95700,
"end": 101580,
"text": "救护车上常用的呼吸机、除颤仪,属于二类医疗器械。",
},
]
def test_merge_subtitle_segments_groups_two_character_sentence():
segments = [
{"start": 12450, "end": 12690, "text": "突然。"},
{"start": 12750, "end": 15150, "text": "捐十万立刻被全网吹成仗义好人。"},
]
assert cli.merge_subtitle_segments(segments) == [
{
"start": 12450,
"end": 15150,
"text": "突然。捐十万立刻被全网吹成仗义好人。",
}
]
def test_merge_subtitle_segments_preserves_hard_boundaries():
segments = [
{"start": 0, "end": 1200, "text": "第一句。", "speaker": 0},
{"start": 1200, "end": 2600, "text": "第二句。", "speaker": 0},
{"start": 2700, "end": 3000, "text": "甲,", "speaker": 0},
{"start": 3100, "end": 3600, "text": "乙。", "speaker": 1},
{"start": 5000, "end": 5300, "text": "丙,", "speaker": 1},
{"start": 6000, "end": 6500, "text": "丁。", "speaker": 1},
]
assert cli.merge_subtitle_segments(segments) == segments
+7
View File
@@ -18,6 +18,12 @@ def test_generate_subtitle_requests_sentence_timestamps_and_writes_segmented_srt
module = load_generate_subtitle_module()
captured = {}
def fake_merge_subtitle_segments(segments):
captured["merge_segments"] = segments
return segments
monkeypatch.setattr(module, "merge_subtitle_segments", fake_merge_subtitle_segments)
class FakeAutoModel:
def __init__(self, **kwargs):
captured["model_kwargs"] = kwargs
@@ -66,6 +72,7 @@ def test_generate_subtitle_requests_sentence_timestamps_and_writes_segmented_srt
assert captured["generate_kwargs"]["sentence_timestamp"] is True
assert captured["generate_kwargs"]["output_timestamp"] is True
assert captured["generate_kwargs"]["return_time_stamps"] is True
assert len(captured["merge_segments"]) == 2
assert output_path.read_text(encoding="utf-8") == (
"1\n"
"00:00:00,000 --> 00:00:03,500\n"