Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions vllm_plugin/asr_streaming.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions vllm_plugin/tests/test_asr_streaming_repeat.py
Original file line number Diff line number Diff line change
@@ -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")