mirror of
https://github.com/modelscope/FunASR.git
synced 2026-10-12 02:19:18 +00:00
feat(cli): group readable subtitle cues
Signed-off-by: LauraGPT <[email protected]>
This commit is contained in:
+8
-1
@@ -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):
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user