Files
FunASR/tests/test_fun_asr_nano_punctuation_timestamps.py
Bruce-YiiandBruce-Yii 41778c45de fix(nano): correct punctuation timestamps across VAD merges (#3703)
* fix(nano): correct punctuation timestamps across VAD merges

Anchor zero-acoustic-mass punctuation at the preceding spoken token end (FunASR #3702). Reviewed patch only.

* fix(nano): keep byte-fragment spoken timestamps

* test(nano): cover byte-fragment timestamp preservation

* fix(nano): preserve semantic symbol timestamp spans

* test(nano): preserve semantic symbol timestamp spans

---------

Signed-off-by: Bruce-Yii <[email protected]>
Co-authored-by: Bruce-Yii <[email protected]>
2026-09-25 10:16:47 +08:00

165 lines
6.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Punctuation-timestamp anchoring for Fun-ASR-Nano forced alignment.
Issue #3702: with merge_vad, a sentence-final punctuation token (which has no
acoustic realization) is placed by CTC forced alignment at the next sentence's
onset frame, so the current sentence's punctuation timestamp lands on the next
sentence start. Proven with synthetic peaky emissions through the repo's exact
``forced_align`` (see run evidence): in a merged [sent1 + silence + sent2]
window the punctuation lands at the silence end, while the single-sentence
control keeps it at the window end.
These tests pin the post-processing rule: a punctuation-only token that is
followed by more speech is anchored (zero-width) at the preceding spoken
token's end. Punctuation carries no acoustic extent, so anchoring invents no
duration. Leading punctuation (no predecessor) and trailing punctuation (no
successor, e.g. single-segment output) are left untouched, preserving
existing single-segment behavior.
"""
import copy
import pytest
from funasr.models.fun_asr_nano.tools.utils import anchor_punctuation_timestamps
def _ts(token, start, end):
return {"token": token, "start_time": start, "end_time": end}
def test_punctuation_anchored_at_previous_spoken_end():
# Mirrors the proven repro: 好 ends at 19, 。misplaced at [72, 73).
timestamps = [_ts("好", 18, 19), _ts("。", 72, 73), _ts("他", 78, 79)]
result = anchor_punctuation_timestamps(timestamps)
assert result[1] == {"token": "。", "start_time": 19, "end_time": 19}
assert result[0] == {"token": "好", "start_time": 18, "end_time": 19}
assert result[2] == {"token": "他", "start_time": 78, "end_time": 79}
def test_leading_punctuation_without_predecessor_is_kept():
timestamps = [_ts("。", 5, 6), _ts("好", 18, 19)]
before = copy.deepcopy(timestamps)
assert anchor_punctuation_timestamps(timestamps) == before
def test_consecutive_punctuation_share_one_anchor():
timestamps = [_ts("好", 18, 19), _ts(",", 50, 51), _ts("。", 72, 73), _ts("他", 78, 79)]
result = anchor_punctuation_timestamps(timestamps)
assert (result[1]["start_time"], result[1]["end_time"]) == (19, 19)
assert (result[2]["start_time"], result[2]["end_time"]) == (19, 19)
assert (result[3]["start_time"], result[3]["end_time"]) == (78, 79)
def test_spoken_tokens_are_never_rewritten():
timestamps = [_ts("你", 12, 13), _ts("好", 18, 19), _ts("。", 39, 40)]
before = copy.deepcopy(timestamps)
result = anchor_punctuation_timestamps(timestamps)
assert result[0] == before[0]
# Single-sentence control shape: punctuation already at window end is kept.
assert result[2] == before[2]
def test_special_tokens_are_not_treated_as_punctuation():
timestamps = [_ts("<sil>", 0, 5), _ts("好", 18, 19)]
before = copy.deepcopy(timestamps)
assert anchor_punctuation_timestamps(timestamps) == before
def test_punctuation_followed_only_by_special_is_kept():
# No later real speech: the trailing <sil> must not make 。 eligible.
timestamps = [_ts("好", 18, 19), _ts("。", 72, 73), _ts("<sil>", 80, 85)]
before = copy.deepcopy(timestamps)
assert anchor_punctuation_timestamps(timestamps) == before
def test_special_token_is_not_used_as_predecessor_anchor():
# The <sil> between speech and punctuation must not become the anchor.
timestamps = [_ts("好", 18, 19), _ts("<sil>", 30, 35), _ts("。", 72, 73), _ts("他", 78, 79)]
result = anchor_punctuation_timestamps(timestamps)
assert (result[2]["start_time"], result[2]["end_time"]) == (19, 19)
assert (result[1]["start_time"], result[1]["end_time"]) == (30, 35)
assert (result[3]["start_time"], result[3]["end_time"]) == (78, 79)
def test_pipeline_segment_local_anchor_boundary(monkeypatch):
"""Pipeline segment boundary: anchoring applies per VAD segment, never
across segments. segA shows intra-segment anchoring (misplaced 。 before
later speech in the SAME aligned segment anchors); segB shows a
segment-final 。 staying put even though segC contains later speech;
VAD offsets and spoken spans pass through verbatim."""
import types
import numpy as np
import torch
from funasr.models.fun_asr_nano.inference_vllm_pipeline import FunASRNanoVLLMPipeline
id2char = {2: "好", 3: "。", 4: "他", 5: "在", 6: "是"}
spans = iter(
[
[
{"token": 2, "start_time": 18, "end_time": 19},
{"token": 3, "start_time": 72, "end_time": 73},
{"token": 4, "start_time": 78, "end_time": 79},
],
[
{"token": 6, "start_time": 10, "end_time": 12},
{"token": 3, "start_time": 40, "end_time": 41},
],
[{"token": 5, "start_time": 50, "end_time": 56}],
]
)
class StubTok:
def encode(self, text):
return [1] * max(1, len(text))
def decode(self, ids):
return "".join(id2char.get(i, "?") for i in ids)
engine = types.SimpleNamespace(
frontend=object(),
device="cpu",
blank_id=0,
audio_encoder=lambda s, sl: (torch.zeros(1, 10, 8), torch.tensor([10])),
ctc_decoder=lambda e, l: (torch.zeros(1, 10, 8), torch.tensor([10])),
ctc=types.SimpleNamespace(log_softmax=lambda d: d),
ctc_tokenizer=StubTok(),
)
monkeypatch.setattr(
"funasr.utils.load_utils.extract_fbank",
lambda *a, **k: (torch.zeros(1, 4, 80), torch.tensor([[4]])),
)
monkeypatch.setattr(
"funasr.models.fun_asr_nano.tools.utils.forced_align",
lambda *a, **k: next(spans),
)
pipeline = FunASRNanoVLLMPipeline.__new__(FunASRNanoVLLMPipeline)
pipeline.asr_engine = engine
pipeline.device = "cpu"
result = pipeline._compute_all_timestamps(
[
np.zeros(32000, dtype=np.float32),
np.zeros(16000, dtype=np.float32),
np.zeros(16000, dtype=np.float32),
],
[[1000, 3000], [5000, 6000], [9000, 10000]],
["segAtext", "segBtext", "segCtext"],
)
assert [t["token"] for t in result] == ["好", "。", "他", "是", "。", "在"]
# segA (offset 1.0s): intra-segment 。 anchors at 好.end bit-identically.
assert result[1]["start_time"] == result[0]["end_time"]
assert result[1]["end_time"] == result[0]["end_time"]
assert result[0]["end_time"] == pytest.approx(2.14)
assert result[2]["start_time"] == pytest.approx(5.68)
# segB (offset 5.0s): segment-final 。 keeps its aligned span even though
# segC contains later speech — cross-segment speech must not trigger it.
assert result[4]["start_time"] == pytest.approx(7.4)
assert result[4]["end_time"] == pytest.approx(7.46)
assert result[3]["end_time"] == pytest.approx(5.72)
# segC (offset 9.0s) passes through untouched.
assert result[5]["start_time"] == pytest.approx(12.0)
assert result[5]["end_time"] == pytest.approx(12.36)