Skip to content

Commit 971381d

Browse files
committed
feat(engine): detect and discard silent recordings
Adds RMS-based silence detection to discard recordings where no speech was detected, saving API costs and improving user experience. - Add _is_silent() function using numpy RMS calculation - Discard silent recordings at /record/stop endpoint - Skip LLM processing for empty transcriptions in pipeline - Add tests for silence detection and empty text handling Release-As: 0.2.0
1 parent 25a51c9 commit 971381d

4 files changed

Lines changed: 128 additions & 1 deletion

File tree

engine/aurotype_engine/pipeline.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,14 @@ async def process_voice_input(audio_bytes: bytes, config: Settings) -> dict[str,
4343
f"STT failed after {_MAX_STT_RETRIES} attempts: {last_error}"
4444
) from last_error
4545

46+
# Nothing was spoken — skip LLM and return empty results
47+
if not raw_text.strip():
48+
return {
49+
"raw_text": "",
50+
"polished_text": "",
51+
"audio_data": base64.b64encode(audio_bytes).decode("ascii"),
52+
}
53+
4654
llm = get_llm_provider(config.llm_provider, config)
4755
polished_text = await llm.polish(raw_text, language=config.language)
4856

engine/aurotype_engine/server.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,21 @@ def _audio_duration_ms(wav_bytes: bytes) -> float:
141141
return wav.getnframes() / wav.getframerate() * 1000
142142

143143

144+
def _is_silent(wav_bytes: bytes, threshold: float = 0.005) -> bool:
145+
"""Return True if the RMS volume of the WAV is below *threshold* (silence)."""
146+
import io
147+
import wave
148+
149+
import numpy as np
150+
151+
with wave.open(io.BytesIO(wav_bytes), "rb") as wav:
152+
frames = wav.readframes(wav.getnframes())
153+
samples = np.frombuffer(frames, dtype=np.int16).astype(np.float32)
154+
if samples.size == 0:
155+
return True
156+
rms = float(np.sqrt(np.mean(samples * samples)) / 32768.0)
157+
return rms < threshold
158+
144159
@app.post("/record/stop")
145160
async def stop_recording():
146161
try:
@@ -155,6 +170,11 @@ async def stop_recording():
155170
print(f"[aurotype] Recording too short ({duration_ms:.0f}ms), discarding")
156171
return {"too_short": True, "duration_ms": duration_ms}
157172

173+
# Discard silent recordings — nothing was spoken
174+
if _is_silent(audio_bytes):
175+
print("[aurotype] Recording is silent, discarding")
176+
return {"silent": True}
177+
158178
cfg = get_effective_settings()
159179
try:
160180
return await process_voice_input(audio_bytes, cfg)

tests/test_pipeline.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,3 +136,48 @@ def test_base64_audio_encoding_in_result() -> None:
136136

137137
decoded = base64.b64decode(result["audio_data"])
138138
assert decoded == audio
139+
140+
141+
def test_empty_transcription_skips_llm() -> None:
142+
"""When STT returns empty text (silence), LLM should not be called."""
143+
audio = b"silent-audio"
144+
config = _build_config()
145+
146+
mock_stt = MagicMock()
147+
mock_stt.transcribe = AsyncMock(return_value="")
148+
149+
mock_llm = MagicMock()
150+
mock_llm.polish = AsyncMock(return_value="should not be called")
151+
152+
with (
153+
patch("aurotype_engine.pipeline.get_stt_provider", return_value=mock_stt),
154+
patch("aurotype_engine.pipeline.get_llm_provider", return_value=mock_llm),
155+
):
156+
result = asyncio.run(process_voice_input(audio, config))
157+
158+
assert result["raw_text"] == ""
159+
assert result["polished_text"] == ""
160+
assert result["audio_data"] == base64.b64encode(audio).decode("ascii")
161+
mock_llm.polish.assert_not_awaited()
162+
163+
164+
def test_whitespace_transcription_skips_llm() -> None:
165+
"""When STT returns only whitespace, treat as empty and skip LLM."""
166+
audio = b"silent-audio"
167+
config = _build_config()
168+
169+
mock_stt = MagicMock()
170+
mock_stt.transcribe = AsyncMock(return_value=" \n ")
171+
172+
mock_llm = MagicMock()
173+
mock_llm.polish = AsyncMock(return_value="should not be called")
174+
175+
with (
176+
patch("aurotype_engine.pipeline.get_stt_provider", return_value=mock_stt),
177+
patch("aurotype_engine.pipeline.get_llm_provider", return_value=mock_llm),
178+
):
179+
result = asyncio.run(process_voice_input(audio, config))
180+
181+
assert result["raw_text"] == ""
182+
assert result["polished_text"] == ""
183+
mock_llm.polish.assert_not_awaited()

tests/test_server.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,33 @@
11
# pyright: reportMissingImports=false, reportUnknownVariableType=false, reportUnknownMemberType=false, reportUnknownArgumentType=false, reportAny=false
22

3+
import io
4+
import struct
35
import sys
6+
import wave
47
from pathlib import Path
58
from unittest.mock import AsyncMock, MagicMock, patch
69

710
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "engine"))
811

9-
from aurotype_engine.server import app, _config_overrides
12+
from aurotype_engine.server import app, _config_overrides, _is_silent
1013

1114
from fastapi.testclient import TestClient
1215

1316
client = TestClient(app)
1417

1518

19+
def _make_wav(samples: list[int], sample_rate: int = 16000) -> bytes:
20+
"""Create a WAV file bytes from a list of 16-bit signed samples."""
21+
buf = io.BytesIO()
22+
with wave.open(buf, "wb") as wav:
23+
wav.setnchannels(1)
24+
wav.setsampwidth(2) # 16-bit
25+
wav.setframerate(sample_rate)
26+
raw = struct.pack(f"<{len(samples)}h", *samples)
27+
wav.writeframes(raw)
28+
return buf.getvalue()
29+
30+
1631
def test_health_endpoint() -> None:
1732
"""GET /health returns status ok and version."""
1833
response = client.get("/health")
@@ -88,3 +103,42 @@ def test_process_endpoint() -> None:
88103
assert data["raw_text"] == "raw"
89104
assert data["polished_text"] == "polished"
90105
assert data["audio_data"] == "base64data"
106+
107+
108+
# --- _is_silent tests ---
109+
110+
111+
def test_is_silent_returns_true_for_silent_audio() -> None:
112+
"""Audio with very low amplitude is detected as silent."""
113+
# All zeros = complete silence
114+
silent_wav = _make_wav([0, 0, 0, 0, 0])
115+
assert _is_silent(silent_wav) is True
116+
117+
118+
def test_is_silent_returns_true_for_very_quiet_audio() -> None:
119+
"""Audio with RMS below threshold is detected as silent."""
120+
# Small values: RMS ~ 0.00003, well below default threshold 0.005
121+
quiet_wav = _make_wav([1, -1, 2, -2, 1])
122+
assert _is_silent(quiet_wav) is True
123+
124+
125+
def test_is_silent_returns_false_for_loud_audio() -> None:
126+
"""Audio with RMS above threshold is not silent."""
127+
# Large values: RMS ~ 0.5, well above threshold
128+
loud_wav = _make_wav([16000, -16000, 16000, -16000])
129+
assert _is_silent(loud_wav) is False
130+
131+
132+
def test_is_silent_returns_true_for_empty_samples() -> None:
133+
"""WAV with no samples is treated as silent."""
134+
empty_wav = _make_wav([])
135+
assert _is_silent(empty_wav) is True
136+
137+
138+
def test_is_silent_respects_custom_threshold() -> None:
139+
"""Custom threshold can make quiet audio non-silent."""
140+
quiet_wav = _make_wav([100, -100, 100, -100])
141+
# Default threshold 0.005: quiet_wav is silent
142+
assert _is_silent(quiet_wav) is True
143+
# Lower threshold: same audio is not silent
144+
assert _is_silent(quiet_wav, threshold=0.001) is False

0 commit comments

Comments
 (0)