fix(bridge): gate STT on real speech so noise doesn't trigger replies
The bridge transcribe path joined every Whisper segment unconditionally, so a
brief loud sound or background noise that momentarily opened the mic gate (no
real speech) still produced a transcript, and Whisper's noise hallucinations
("감사합니다", "MBC 뉴스", ...) made the bot reply to nothing.
Add bridge/stt_filter.py mirroring the desktop listener's _filter_noisy_segments
policy: a hard no_speech_prob cutoff (whisper_no_speech_threshold) plus an
avg_logprob confidence floor (whisper_min_confidence), both config-driven. Apply
it in transcribe() so only segments that look like human speech survive; a
noise-only turn yields an empty transcript and the existing empty-transcript
guard drops it with no reply. Add unit tests for the gate.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
102
tests/test_bridge_stt_filter.py
Normal file
102
tests/test_bridge_stt_filter.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Unit tests for the bridge STT speech gate.
|
||||
|
||||
The gate decides whether a Whisper segment is real human speech or just noise /
|
||||
a brief loud blip that Whisper hallucinated text from. Only speech should reach
|
||||
the reply engine, so a noisy mic that momentarily opens without anyone speaking
|
||||
produces no transcript and no reply. Thresholds are config-driven, so the tests
|
||||
pass explicit references rather than hardcoding the production defaults.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from bridge.stt_filter import (
|
||||
filter_speech_segments,
|
||||
is_non_speech,
|
||||
segment_confidence,
|
||||
)
|
||||
|
||||
|
||||
class Seg:
|
||||
"""Minimal stand-in for a faster-whisper segment."""
|
||||
|
||||
def __init__(self, text, no_speech_prob=0.0, avg_logprob=0.0):
|
||||
self.text = text
|
||||
self.no_speech_prob = no_speech_prob
|
||||
self.avg_logprob = avg_logprob
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_real_speech_is_kept():
|
||||
seg = Seg("오늘 일정 알려줘", no_speech_prob=0.02, avg_logprob=-0.2)
|
||||
assert filter_speech_segments(
|
||||
[seg], no_speech_threshold=0.5, min_confidence=0.3
|
||||
) == [seg]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_noise_with_high_no_speech_prob_is_dropped():
|
||||
# A mic blip Whisper hallucinated "감사합니다" from: not speech.
|
||||
seg = Seg("감사합니다", no_speech_prob=0.92, avg_logprob=0.5)
|
||||
assert filter_speech_segments(
|
||||
[seg], no_speech_threshold=0.5, min_confidence=0.3
|
||||
) == []
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_no_speech_cutoff_runs_before_the_confidence_check():
|
||||
# Confident hallucination: high avg_logprob but also high no_speech_prob.
|
||||
# The no-speech cutoff must catch it regardless of confidence.
|
||||
seg = Seg("MBC 뉴스", no_speech_prob=0.8, avg_logprob=0.9)
|
||||
assert filter_speech_segments(
|
||||
[seg], no_speech_threshold=0.5, min_confidence=0.3
|
||||
) == []
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_low_confidence_decode_is_dropped():
|
||||
# avg_logprob -0.8 -> confidence 0.2, below the 0.3 floor.
|
||||
seg = Seg("어버버", no_speech_prob=0.1, avg_logprob=-0.8)
|
||||
assert filter_speech_segments(
|
||||
[seg], no_speech_threshold=0.5, min_confidence=0.3
|
||||
) == []
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_order_preserved_dropping_only_non_speech():
|
||||
a = Seg("진짜 말한 문장", no_speech_prob=0.05, avg_logprob=-0.1)
|
||||
noise = Seg("음", no_speech_prob=0.95, avg_logprob=0.4)
|
||||
b = Seg("두 번째 문장", no_speech_prob=0.05, avg_logprob=-0.1)
|
||||
kept = filter_speech_segments(
|
||||
[a, noise, b], no_speech_threshold=0.5, min_confidence=0.3
|
||||
)
|
||||
assert kept == [a, b]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_segments_missing_metadata_are_kept():
|
||||
# No no_speech_prob / avg_logprob -> we can't prove it's noise, so keep it.
|
||||
class Bare:
|
||||
text = "메타데이터 없는 문장"
|
||||
|
||||
seg = Bare()
|
||||
assert filter_speech_segments(
|
||||
[seg], no_speech_threshold=0.5, min_confidence=0.3
|
||||
) == [seg]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_is_non_speech_uses_an_inclusive_threshold():
|
||||
assert is_non_speech(0.5, 0.5) is True
|
||||
assert is_non_speech(0.49, 0.5) is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_segment_confidence_prefers_avg_logprob():
|
||||
assert segment_confidence(Seg("x", avg_logprob=-0.2)) == pytest.approx(0.8)
|
||||
# Falls back to 1 - no_speech_prob when avg_logprob is absent.
|
||||
|
||||
class NoLogprob:
|
||||
text = "x"
|
||||
no_speech_prob = 0.25
|
||||
|
||||
assert segment_confidence(NoLogprob()) == pytest.approx(0.75)
|
||||
Reference in New Issue
Block a user