|
| 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