diff --git a/vllm_plugin/asr_streaming.py b/vllm_plugin/asr_streaming.py index c97412fc..7c4274a2 100644 --- a/vllm_plugin/asr_streaming.py +++ b/vllm_plugin/asr_streaming.py @@ -200,6 +200,34 @@ def load_audio_bytes(audio_bytes: bytes, target_sr: int) -> np.ndarray: # merged into one card, which is why this runs here and not per block. _REPEAT_TAG_RE = re.compile(r"(\[[^\[\]]*\])(?:\s*\1)+") +# A decode loop -- the model failure behind issue #415, usually triggered by a +# stretch of non-speech -- emits the same text chunk after chunk, which renders +# as that clause repeated down the transcript. A run of REPEAT_LOOP_MIN or more +# byte-identical consecutive chunks is that loop: a real audio window rarely +# transcribes identically twice, let alone three times running. One or two +# repeats stay, since they can be genuine speech. +REPEAT_LOOP_MIN = 3 + + +def mute_looped_chunks(chunk_texts: List[str]) -> List[str]: + """Blank all but the first of each long run of identical consecutive chunks. + + Blanking, not dropping: chunk k's timestamp is ``k * chunk_seconds``, so the + slots have to stay. An emptied chunk folds into the open segment exactly as a + silent one does, which is what keeps a loop from repeating across the cards. + """ + out = list(chunk_texts) + i = 0 + while i < len(out): + j = i + 1 + while j < len(out) and out[j] == out[i]: + j += 1 + if out[i].strip() and j - i >= REPEAT_LOOP_MIN: + for k in range(i + 1, j): + out[k] = "" + i = j + return out + # A card is one paragraph, so the line breaks the model emits inside a block # fold to single spaces -- except between two CJK characters, where a space is # not a word separator and just leaves a visible gap. @@ -230,6 +258,7 @@ def chunk_segments(chunk_texts: List[str], ``duration`` only ever clamps the tail. While a clip is still streaming its real duration is unknown, and the chunk count is what defines the timeline. """ + chunk_texts = mute_looped_chunks(chunk_texts) advance = geometry.chunk_seconds segments: List[Dict] = [] cur_start = 0.0 diff --git a/vllm_plugin/tests/test_asr_streaming_repeat.py b/vllm_plugin/tests/test_asr_streaming_repeat.py new file mode 100644 index 00000000..fffcd52a --- /dev/null +++ b/vllm_plugin/tests/test_asr_streaming_repeat.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Regression test for issue #415: a decode loop repeats the same clause across +chunks, and chunk_segments used to render every copy verbatim. + +Torch-free: imports asr_streaming directly (numpy only) the same way the demo +does, so it runs without weights, a GPU, or a vLLM install. Run with: + + python3 vllm_plugin/tests/test_asr_streaming_repeat.py +""" +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import asr_streaming as a # noqa: E402 + +# Released streaming geometry: 15-frame chunk, 4-frame lookahead at 24 kHz. +GEOMETRY = a.ChunkGeometry( + sample_rate=24000, frame_samples=3200, chunk_frames=15, lookahead_frames=4) + +CLAUSE = "the cat sat on the mat. " +LOOP_CHUNKS = 8 # a stuck model emits the same clause every chunk +EMPHASIS = "No. No. " # two repeats: real speech, must survive + + +def _content(chunk_texts): + segs = a.chunk_segments(chunk_texts, GEOMETRY) + return " ".join(seg["Content"] for seg in segs) + + +def test_loop_collapses(): + body = _content([f"Speaker 0: {CLAUSE}"] * LOOP_CHUNKS) + hits = body.count("the cat sat on the mat") + print(f"[loop] {LOOP_CHUNKS} looped chunks -> clause appears {hits}x: {body!r}") + assert hits == 1, f"decode loop still repeats the clause {hits}x (issue #415)" + + +def test_emphasis_survives(): + # A short genuine repeat is below the loop threshold and stays untouched. + body = _content([f"Speaker 0: {EMPHASIS}"]) + hits = body.count("No") + print(f"[emphasis] genuine double repeat -> 'No' appears {hits}x: {body!r}") + assert hits == 2, f"conservative threshold ate real repetition: {body!r}" + + +if __name__ == "__main__": + test_loop_collapses() + test_emphasis_survives() + print("PASS")