Skip to content

Commit 57e2f48

Browse files
authored
fix(tokenizer): treat special-token strings as text to prevent batch crashes (#3110)
Fixes #3110.
1 parent f993738 commit 57e2f48

2 files changed

Lines changed: 125 additions & 2 deletions

File tree

funasr/models/sense_voice/whisper_lib/tokenizer.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,11 +194,24 @@ def __post_init__(self):
194194

195195
def encode(self, text, **kwargs):
196196
"""Encode.
197-
197+
198198
Args:
199199
text: Text tensor or string input.
200200
**kwargs: Additional keyword arguments.
201-
"""
201+
202+
``tiktoken`` rejects any text containing a special-token string
203+
(e.g. ``<|no|>``, ``<|zh|>``, ``<|nospeech|>``) by default
204+
(``disallowed_special="all"``). ASR models such as Fun-ASR-Nano
205+
occasionally emit such special-token strings as part of their
206+
transcription text; re-encoding that text for downstream tasks
207+
(forced alignment, loss computation, ...) then crashes the whole
208+
batch with a single bad sample (see issue #3110).
209+
210+
Treat special-token strings as ordinary text unless the caller
211+
explicitly opts into a stricter policy. Callers that already pass
212+
``allowed_special`` / ``disallowed_special`` are left untouched.
213+
"""
214+
kwargs.setdefault("disallowed_special", ())
202215
return self.encoding.encode(text, **kwargs)
203216

204217
def decode(self, token_ids: List[int], **kwargs) -> str:
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""Unit tests for SenseVoice Tokenizer handling of special-token strings.
2+
3+
Regression guard for issue #3110: ASR models such as Fun-ASR-Nano occasionally
4+
emit special-token strings (e.g. ``<|no|>``, a language tag) as part of the
5+
transcription text. Re-encoding that text via ``tiktoken.Encoding.encode``
6+
crashes by default (``disallowed_special="all"``), which takes down the whole
7+
batch on a single bad sample during forced alignment / loss computation.
8+
``Tokenizer.encode`` now treats special-token strings as ordinary text unless
9+
the caller explicitly opts into a stricter policy.
10+
11+
These tests build a minimal in-memory tiktoken encoding, so they run without
12+
the Fun-ASR-Nano ``multilingual.tiktoken`` vocab (which ships with the model,
13+
not this repo) and without a GPU or model download.
14+
"""
15+
16+
import unittest
17+
18+
import tiktoken
19+
20+
from funasr.models.sense_voice.whisper_lib.tokenizer import Tokenizer
21+
22+
23+
def _build_test_encoding() -> tiktoken.Encoding:
24+
"""A minimal byte-level BPE encoding with the specials Tokenizer needs.
25+
26+
Every single byte is its own token, so arbitrary text can be encoded
27+
without the model's multilingual.tiktoken vocab file.
28+
"""
29+
mergeable_ranks = {bytes([b]): b for b in range(256)}
30+
n_vocab = len(mergeable_ranks)
31+
32+
# Specials required by Tokenizer.__post_init__ (startoftranscript/translate/
33+
# transcribe) plus a few language tags. "<|no|>" is the Norwegian tag that
34+
# triggered #3110.
35+
required_specials = [
36+
"<|startoftranscript|>",
37+
"<|no|>",
38+
"<|zh|>",
39+
"<|en|>",
40+
"<|translate|>",
41+
"<|transcribe|>",
42+
"<|startoflm|>",
43+
"<|startofprev|>",
44+
"<|nospeech|>",
45+
"<|notimestamps|>",
46+
"<|0.00|>",
47+
]
48+
special_tokens = {}
49+
for tok in required_specials:
50+
special_tokens[tok] = n_vocab
51+
n_vocab += 1
52+
53+
return tiktoken.Encoding(
54+
name="test-encoding",
55+
pat_str=(
56+
r"""'s|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| """
57+
r"""?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+"""
58+
),
59+
mergeable_ranks=mergeable_ranks,
60+
special_tokens=special_tokens,
61+
)
62+
63+
64+
def _build_test_tokenizer() -> Tokenizer:
65+
return Tokenizer(encoding=_build_test_encoding(), num_languages=1)
66+
67+
68+
class TestTokenizerEncodeSpecialTokens(unittest.TestCase):
69+
def setUp(self):
70+
self.tokenizer = _build_test_tokenizer()
71+
self.no_id = self.tokenizer.special_tokens["<|no|>"]
72+
73+
def test_plain_text_encodes(self):
74+
ids = self.tokenizer.encode("hello world")
75+
self.assertIsInstance(ids, list)
76+
self.assertTrue(ids)
77+
78+
def test_language_tag_in_text_does_not_raise(self):
79+
# The exact failure from issue #3110: ASR output containing "<|no|>"
80+
# used to crash with "disallowed special token '<|no|>'".
81+
ids = self.tokenizer.encode("hello <|no|> world")
82+
self.assertIsInstance(ids, list)
83+
self.assertTrue(ids)
84+
85+
def test_nospeech_tag_in_text_does_not_raise(self):
86+
ids = self.tokenizer.encode("<|nospeech|> something")
87+
self.assertIsInstance(ids, list)
88+
self.assertTrue(ids)
89+
90+
def test_language_tag_alone_does_not_raise(self):
91+
ids = self.tokenizer.encode("<|no|>")
92+
self.assertIsInstance(ids, list)
93+
self.assertTrue(ids)
94+
95+
def test_caller_disallowed_special_is_respected(self):
96+
# A caller that explicitly wants the strict behaviour must still get
97+
# it (setdefault must not override an explicit kwargs).
98+
with self.assertRaises(ValueError):
99+
self.tokenizer.encode("<|no|>", disallowed_special="all")
100+
101+
def test_allowed_special_all_keeps_special_token_ids(self):
102+
# funasr/models/sense_voice/whisper_lib/decoding.py calls
103+
# tokenizer.encode(prompt, allowed_special="all"); the special token
104+
# must still be encoded as its token id, not as raw bytes.
105+
ids = self.tokenizer.encode("<|no|>", allowed_special="all")
106+
self.assertEqual(ids, [self.no_id])
107+
108+
109+
if __name__ == "__main__":
110+
unittest.main()

0 commit comments

Comments
 (0)