* fix(audio): bound capture reads and support cancellation * fix(recognition): bound and serialize capture shutdown * fix(recognition): cancel retries and report stalled microphones * fix(audio): prefer pulse when the ALSA default is unavailable * fix(recognition): guard late callbacks and finalize capture tests * docs(audio): explain input selection and stall recovery * fix(speech): enforce auto-pause across buffered reload timeouts * fix(speech): recover auto-resume after reload timeout * test(speech): fix auto-pause follow-up test regressions * fix(speech): retain auto-resume after reconfigure timeout * fix(tray): preserve auto-pause recovery across suspend * test(capture-shutdown): give the bare tray the suspend path's shortcut managers _on_system_suspend now releases the main and per-language hotkey listeners before sleep (#1025); the fixture builds TrayIndicator without __init__, so it needs the two attributes present. * fix(tray): release hotkey listeners on suspend even while auto-paused Merging #1025's suspend cleanup with this branch's auto-pause handoff left an early return that skipped shortcut_manager.stop() whenever a configured game was active, keeping EVIOCGRABs held through sleep. The paused branch now only owns the model-lifecycle work; listener release and stopping active recognition run for every suspend. * fix(tray): hold the apply lock only around the download's verify and save Wrapping the whole recommended-model download in _apply_settings_lock blocked every GTK-thread settings apply (e.g. Test Dictation) for the length of the transfer. The engine already serializes reconfigures, so the lock only needs to cover verifying the download still owns the receipt and saving it; an apply that lands mid-download now proceeds instead of freezing the app. --------- Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
540 lines
21 KiB
Python
540 lines
21 KiB
Python
"""
|
|
Integration-style tests for the Silero VAD path in _record_audio.
|
|
|
|
Drives the recording loop with a mocked PyAudio stream and a Mock SileroVAD
|
|
returning scripted speech probabilities. Verifies:
|
|
|
|
- silence-only input -> no transcription segment is enqueued
|
|
- speech followed by silence -> _enqueue_audio_segment is called after silence_timeout
|
|
- speech -> silence_counter resets, no premature flush
|
|
- vad_sensitivity threshold mapping kicks in (sens=1 vs sens=5 give different decisions)
|
|
- Silero state is reset at the start of every recording session
|
|
"""
|
|
|
|
import sys
|
|
import threading
|
|
import time
|
|
import unittest # noqa: E402
|
|
from typing import Optional
|
|
from unittest.mock import MagicMock, patch # noqa: E402
|
|
|
|
# Earlier test modules install `sys.modules["numpy"] = MagicMock()` at module
|
|
# load and don't restore it. Reuse the real module cached by conftest instead
|
|
# of unloading/re-importing NumPy's compiled extensions.
|
|
if isinstance(sys.modules.get("numpy"), MagicMock):
|
|
_real_numpy = getattr(sys, "_vocalinux_real_numpy", None)
|
|
if _real_numpy is not None:
|
|
sys.modules["numpy"] = _real_numpy
|
|
|
|
import numpy as np # noqa: E402
|
|
|
|
# ---- Mock external deps that recognition_manager imports ----
|
|
_MOCK_KEYS = ["vosk", "whisper", "torch", "pyaudio", "pywhispercpp", "pywhispercpp.model"]
|
|
_ORIG_MODULES = {}
|
|
for _k in _MOCK_KEYS:
|
|
_ORIG_MODULES[_k] = sys.modules.get(_k)
|
|
if _k not in sys.modules:
|
|
sys.modules[_k] = MagicMock()
|
|
|
|
if "gi" not in sys.modules:
|
|
sys.modules["gi"] = MagicMock()
|
|
if "gi.repository" not in sys.modules:
|
|
sys.modules["gi.repository"] = MagicMock()
|
|
|
|
from vocalinux.common_types import RecognitionState # noqa: E402
|
|
from vocalinux.speech_recognition.recognition_manager import ( # noqa: E402
|
|
SpeechRecognitionManager,
|
|
_ReleaseGate,
|
|
)
|
|
|
|
for _k, _v in _ORIG_MODULES.items():
|
|
if _v is not None:
|
|
sys.modules[_k] = _v
|
|
elif _k in sys.modules and isinstance(sys.modules[_k], MagicMock):
|
|
del sys.modules[_k]
|
|
|
|
|
|
CHUNK_BYTES = 1024 * 2 # 1024 int16 samples
|
|
SILENT_CHUNK = b"\x00" * (1024 * 2) # 1024 int16 zero samples
|
|
|
|
|
|
def _make_manager():
|
|
"""Create a SpeechRecognitionManager with init paths stubbed out."""
|
|
with (
|
|
patch.object(SpeechRecognitionManager, "_init_vosk"),
|
|
patch.object(SpeechRecognitionManager, "_init_whisper"),
|
|
patch.object(SpeechRecognitionManager, "_init_whispercpp"),
|
|
):
|
|
mgr = SpeechRecognitionManager(
|
|
engine="whisper_cpp",
|
|
model_size="small",
|
|
language="en-us",
|
|
defer_download=True,
|
|
)
|
|
return mgr
|
|
|
|
|
|
class TestManagerSileroInitialization(unittest.TestCase):
|
|
def test_logs_when_silero_vad_loads(self):
|
|
"""Manager initialization should report when neural VAD is active."""
|
|
vad = MagicMock()
|
|
with (
|
|
patch.object(SpeechRecognitionManager, "_init_vosk"),
|
|
patch.object(SpeechRecognitionManager, "_init_whisper"),
|
|
patch.object(SpeechRecognitionManager, "_init_whispercpp"),
|
|
patch(
|
|
"vocalinux.speech_recognition.recognition_manager.load_silero_vad",
|
|
return_value=vad,
|
|
),
|
|
patch("vocalinux.speech_recognition.recognition_manager.logger") as logger_mock,
|
|
):
|
|
mgr = SpeechRecognitionManager(
|
|
engine="whisper_cpp",
|
|
model_size="small",
|
|
language="en-us",
|
|
defer_download=True,
|
|
)
|
|
|
|
self.assertIs(mgr._silero_vad, vad)
|
|
logger_mock.info.assert_any_call("Using Silero neural VAD")
|
|
|
|
|
|
def _make_pyaudio_module(stream):
|
|
"""Build a fake pyaudio module that returns the given stream from audio.open()."""
|
|
audio = MagicMock()
|
|
audio.get_device_count.return_value = 1
|
|
audio.get_default_input_device_info.return_value = {
|
|
"index": 0,
|
|
"name": "mock",
|
|
"maxInputChannels": 1,
|
|
"defaultSampleRate": 16000,
|
|
}
|
|
audio.get_device_info_by_index.return_value = {
|
|
"index": 0,
|
|
"name": "mock",
|
|
"maxInputChannels": 1,
|
|
"defaultSampleRate": 16000,
|
|
}
|
|
audio.is_format_supported.return_value = True
|
|
audio.open.return_value = stream
|
|
|
|
pyaudio_mod = MagicMock(paInt16=8, PyAudio=MagicMock(return_value=audio))
|
|
return pyaudio_mod, audio
|
|
|
|
|
|
def _scripted_stream(probs_per_chunk, manager, max_iters=None, chunk_bytes=None):
|
|
"""Return a Mock stream whose read() drives `manager.should_record` to False
|
|
once we've consumed `max_iters` (or len(probs_per_chunk)) chunks.
|
|
|
|
Each read() returns silent bytes; the speech/silence decision comes from
|
|
the scripted probabilities fed via the mocked SileroVAD. `chunk_bytes`
|
|
overrides the default 1024-int16 size (e.g. stereo at 16 kHz needs 4096
|
|
bytes per CHUNK frame).
|
|
"""
|
|
stream = MagicMock()
|
|
counter = {"n": 0}
|
|
limit = max_iters if max_iters is not None else len(probs_per_chunk)
|
|
payload = b"\x00" * (chunk_bytes if chunk_bytes is not None else 1024 * 2)
|
|
|
|
def _read(*args, **kwargs):
|
|
counter["n"] += 1
|
|
if counter["n"] > limit:
|
|
manager.should_record = False
|
|
return payload
|
|
|
|
stream.get_read_available.return_value = 1024
|
|
stream.read.side_effect = _read
|
|
return stream
|
|
|
|
|
|
def _make_silero(probs):
|
|
"""Mock SileroVAD: process() returns scripted probs, repeating the last
|
|
value forever once the list is exhausted (each outer read() drives 2 silero
|
|
chunks of 512 samples, so we need plenty of values).
|
|
"""
|
|
vad = MagicMock()
|
|
state = {"i": 0, "last": probs[-1] if probs else 0.0}
|
|
|
|
def _process(_chunk):
|
|
i = state["i"]
|
|
if i < len(probs):
|
|
state["i"] = i + 1
|
|
return probs[i]
|
|
return state["last"]
|
|
|
|
vad.process.side_effect = _process
|
|
return vad
|
|
|
|
|
|
class TestRecordAudioSileroPath(unittest.TestCase):
|
|
def setUp(self):
|
|
self.mgr = _make_manager()
|
|
self.mgr.state = RecognitionState.LISTENING
|
|
self.mgr.should_record = True
|
|
self.mgr.audio_buffer = []
|
|
self.mgr.silence_timeout = 0.5 # short timeout for fast tests
|
|
self.mgr.vad_sensitivity = 3
|
|
|
|
# Capture flushes triggered by the silence-detection branch
|
|
self.enqueued = []
|
|
self.enqueued_gates: list[Optional[_ReleaseGate]] = []
|
|
|
|
def enqueue_segment(
|
|
audio_buffer: list[bytes],
|
|
release_gate: Optional[_ReleaseGate] = None,
|
|
) -> None:
|
|
self.enqueued.append(list(audio_buffer))
|
|
self.enqueued_gates.append(release_gate)
|
|
|
|
self.mgr._enqueue_audio_segment = enqueue_segment
|
|
|
|
def _drive(
|
|
self,
|
|
probs,
|
|
vad_sensitivity=3,
|
|
max_iters=None,
|
|
channels=1,
|
|
rate=16000,
|
|
chunk_bytes=None,
|
|
):
|
|
"""Run _record_audio with the given scripted Silero probabilities."""
|
|
self.mgr.vad_sensitivity = vad_sensitivity
|
|
self.mgr._silero_vad = _make_silero(probs)
|
|
|
|
stream = _scripted_stream(
|
|
probs,
|
|
self.mgr,
|
|
max_iters=max_iters or len(probs),
|
|
chunk_bytes=chunk_bytes,
|
|
)
|
|
pyaudio_mod, _audio = _make_pyaudio_module(stream)
|
|
|
|
# _record_audio does `import numpy as np` lazily inside the function;
|
|
# earlier test modules may have left sys.modules["numpy"] as a
|
|
# MagicMock, so put real numpy back for the duration of the call.
|
|
with (
|
|
patch.dict(sys.modules, {"pyaudio": pyaudio_mod, "numpy": np}),
|
|
patch(
|
|
"vocalinux.audio.capture._open_capture_stream",
|
|
return_value=(channels, rate, stream),
|
|
),
|
|
):
|
|
self.mgr._record_audio()
|
|
|
|
def test_reset_called_at_session_start(self):
|
|
"""Silero state must be cleared between recording sessions."""
|
|
self._drive(probs=[0.1] * 5)
|
|
self.assertTrue(self.mgr._silero_vad.reset.called)
|
|
|
|
def test_silence_only_drops_buffer_without_enqueue(self):
|
|
"""Sustained silence past silence_timeout should not reach transcription."""
|
|
# Each chunk = 1024/16000 = 0.064s. silence_timeout=0.5s, so ~8 chunks
|
|
# of silence crosses the timeout. Run 20 chunks to ensure the threshold fires.
|
|
self._drive(probs=[0.05] * 20, vad_sensitivity=3)
|
|
self.assertEqual(len(self.enqueued), 0, "silence-only input should be dropped")
|
|
|
|
def test_speech_then_silence_triggers_segment_enqueue(self):
|
|
"""Speech followed by sustained silence should flush the speech segment."""
|
|
probs = [0.95] * 6 + [0.05] * 20
|
|
self._drive(probs=probs, vad_sensitivity=3)
|
|
self.assertGreaterEqual(
|
|
len(self.enqueued), 1, "expected speech-followed-by-silence to enqueue"
|
|
)
|
|
|
|
def test_speech_prevents_premature_flush(self):
|
|
"""Speech probs above threshold should reset the silence counter."""
|
|
# Continuous speech (prob well above threshold for sens=3 -> 0.55).
|
|
# 20 chunks * 0.064s = 1.28s, more than silence_timeout=0.5s,
|
|
# but speech_counter never accumulates -> no flush.
|
|
self._drive(probs=[0.95] * 20, vad_sensitivity=3)
|
|
self.assertEqual(len(self.enqueued), 0, "speech should keep silence counter at 0, no flush")
|
|
|
|
def test_continuous_speech_flushes_before_buffer_cap(self) -> None:
|
|
"""A pause-free hold must be queued, not trimmed, when it grows long."""
|
|
# 20 chunks * 0.064s = 1.28s. With a 0.5s segment limit the loop
|
|
# queues ~0.5s of audio and starts a fresh segment, twice.
|
|
self.mgr._max_segment_seconds = 0.5
|
|
self._drive(probs=[0.95] * 20, vad_sensitivity=3)
|
|
self.assertEqual(len(self.enqueued), 2)
|
|
self.assertEqual(len(self.mgr.audio_buffer), 4)
|
|
self.assertLess(len(self.mgr.audio_buffer), self.mgr._max_buffer_size)
|
|
|
|
def test_chunk_budget_flushes_before_cap_on_short_chunks(self) -> None:
|
|
"""The 75% chunk-budget limit must win when chunks are short.
|
|
|
|
A high capture rate makes each chunk short, so a 90 s time limit would
|
|
be reached long after the destructive cap. The flush point has to
|
|
derive from the chunk budget in that case, or the trim sees speech.
|
|
"""
|
|
# 10 chunks * 0.064s = 0.64s of buffer, so 75% is 7.5 chunks -> flush
|
|
# at chunk 8, two chunks before the cap could trim.
|
|
self.mgr._max_buffer_size = 10
|
|
self.mgr._max_segment_seconds = 90.0
|
|
self._drive(probs=[0.95] * 10, vad_sensitivity=3)
|
|
self.assertEqual(len(self.enqueued), 1)
|
|
self.assertEqual(len(self.enqueued[0]), 8)
|
|
self.assertEqual(len(self.mgr.audio_buffer), 2)
|
|
self.assertLess(len(self.mgr.audio_buffer), self.mgr._max_buffer_size)
|
|
# Every captured chunk is either queued or still buffered: no trim.
|
|
self.assertEqual(
|
|
sum(len(segment) for segment in self.enqueued) + len(self.mgr.audio_buffer),
|
|
10,
|
|
)
|
|
|
|
def test_speech_during_reload_is_not_flushed(self) -> None:
|
|
"""A model reload keeps the utterance intact rather than enqueuing it."""
|
|
self.mgr._max_segment_seconds = 0.5
|
|
self.mgr._reload_pending = True
|
|
self._drive(probs=[0.95] * 20, vad_sensitivity=3)
|
|
self.assertEqual(self.enqueued, [])
|
|
self.assertEqual(len(self.mgr.audio_buffer), 20)
|
|
|
|
def test_reload_keeps_speech_across_silence(self) -> None:
|
|
"""Speech during reload stays together despite intervening silence."""
|
|
self.mgr._reload_pending = True
|
|
self._drive(probs=[0.95] * 6 + [0.05] * 20)
|
|
self.assertEqual(self.enqueued, [])
|
|
self.assertEqual(len(self.mgr.audio_buffer), 26)
|
|
self.assertTrue(self.mgr._recording_segment_has_speech)
|
|
|
|
def test_reload_buffer_limit_fails_instead_of_continuing_partial_capture(self) -> None:
|
|
"""A full reload buffer must fail the session instead of trimming speech."""
|
|
self.mgr._reload_pending = True
|
|
self.mgr._buffered_reload_session = True
|
|
self.mgr._max_buffer_size = 4
|
|
self._drive(probs=[0.95] * 20)
|
|
self.assertEqual(self.mgr.audio_buffer, [])
|
|
self.assertTrue(self.mgr._buffered_capture_failed)
|
|
self.assertEqual(self.mgr.state, RecognitionState.IDLE)
|
|
|
|
def test_reload_reconnection_failure_marks_capture_failed(self) -> None:
|
|
"""Losing the microphone during reload must fail instead of dropping audio."""
|
|
self.mgr._buffered_reload_session = True
|
|
stream = MagicMock()
|
|
stream.get_read_available.return_value = 1024
|
|
stream.read.side_effect = OSError("microphone disconnected")
|
|
pyaudio_mod, _audio = _make_pyaudio_module(stream)
|
|
|
|
with (
|
|
patch.dict(sys.modules, {"pyaudio": pyaudio_mod, "numpy": np}),
|
|
patch(
|
|
"vocalinux.speech_recognition.recognition_manager._open_capture_stream",
|
|
return_value=(1, 16000, stream),
|
|
),
|
|
patch.object(self.mgr, "_attempt_audio_reconnection", return_value=False),
|
|
):
|
|
self.mgr._record_audio()
|
|
|
|
self.assertTrue(self.mgr._buffered_capture_failed)
|
|
self.assertEqual(self.mgr.state, RecognitionState.IDLE)
|
|
|
|
def test_reload_rapid_audio_error_marks_capture_failed(self) -> None:
|
|
"""A repeated microphone error during reload must fail the capture."""
|
|
self.mgr._buffered_reload_session = True
|
|
self.mgr._last_audio_error_time = time.time()
|
|
stream = MagicMock()
|
|
stream.get_read_available.return_value = 1024
|
|
stream.read.side_effect = OSError("microphone still disconnected")
|
|
pyaudio_mod, _audio = _make_pyaudio_module(stream)
|
|
|
|
with (
|
|
patch.dict(sys.modules, {"pyaudio": pyaudio_mod, "numpy": np}),
|
|
patch(
|
|
"vocalinux.speech_recognition.recognition_manager._open_capture_stream",
|
|
return_value=(1, 16000, stream),
|
|
),
|
|
patch.object(self.mgr, "_attempt_audio_reconnection") as reconnect,
|
|
):
|
|
self.mgr._record_audio()
|
|
|
|
reconnect.assert_not_called()
|
|
self.assertTrue(self.mgr._buffered_capture_failed)
|
|
self.assertEqual(self.mgr.state, RecognitionState.IDLE)
|
|
|
|
def test_sensitivity_lowest_blocks_borderline_speech(self):
|
|
"""At sensitivity=1 (threshold 0.8), prob=0.6 is silence."""
|
|
# 20 chunks of borderline prob; with strict threshold these read as silence
|
|
# and the silence-only buffer gets dropped.
|
|
self._drive(probs=[0.6] * 20, vad_sensitivity=1)
|
|
self.assertEqual(len(self.enqueued), 0)
|
|
|
|
def test_push_to_talk_flushes_carry_the_session_release_gate(self) -> None:
|
|
"""Mid-hold segments carry the session gate, which capture exit opens."""
|
|
self.mgr._recognition_mode = "push_to_talk"
|
|
self.mgr._max_segment_seconds = 0.5
|
|
self.mgr._key_release_gate = _ReleaseGate()
|
|
gate = self.mgr._key_release_gate
|
|
|
|
self._drive(probs=[0.95] * 20, vad_sensitivity=3)
|
|
|
|
self.assertGreater(len(self.enqueued_gates), 0)
|
|
self.assertTrue(all(enqueued_gate is gate for enqueued_gate in self.enqueued_gates))
|
|
self.assertTrue(gate.is_set())
|
|
|
|
def test_toggle_flushes_do_not_carry_a_release_gate(self) -> None:
|
|
"""Toggle-mode segments are not gated even though capture exit opens its event."""
|
|
self.mgr._max_segment_seconds = 0.5
|
|
self.mgr._key_release_gate = _ReleaseGate()
|
|
gate = self.mgr._key_release_gate
|
|
|
|
self._drive(probs=[0.95] * 20, vad_sensitivity=3)
|
|
|
|
self.assertEqual(self.enqueued_gates, [None, None])
|
|
self.assertTrue(gate.is_set())
|
|
|
|
def test_sensitivity_highest_accepts_borderline_speech(self):
|
|
"""At sensitivity=5 (threshold 0.3), prob=0.6 is speech, no flush."""
|
|
self._drive(probs=[0.6] * 20, vad_sensitivity=5)
|
|
self.assertEqual(len(self.enqueued), 0)
|
|
|
|
def test_invalid_sensitivity_uses_default_silero_threshold(self):
|
|
"""Bad sensitivity values should fall back to the default Silero threshold."""
|
|
self._drive(probs=[0.95] * 20, vad_sensitivity="bad")
|
|
self.assertEqual(len(self.enqueued), 0)
|
|
|
|
def test_push_to_talk_defers_speech_segment_until_release(self):
|
|
"""Push-to-talk should keep a spoken segment buffered after silence."""
|
|
self.mgr._recognition_mode = "push_to_talk"
|
|
|
|
self._drive(probs=[0.95] * 6 + [0.05] * 20, vad_sensitivity=3)
|
|
|
|
self.assertEqual(len(self.enqueued), 0)
|
|
self.assertGreater(len(self.mgr.audio_buffer), 0)
|
|
self.assertTrue(self.mgr._recording_segment_has_speech)
|
|
|
|
def test_stereo_capture_is_downmixed_without_error(self):
|
|
"""CHANNELS=2 -> the stereo->mono branch runs and the loop completes."""
|
|
self._drive(probs=[0.05] * 12, vad_sensitivity=3, channels=2, chunk_bytes=1024 * 2 * 2)
|
|
# No assertion on enqueue count -- just verify the path runs cleanly.
|
|
|
|
def test_quad_capture_is_downmixed_without_error(self):
|
|
"""CHANNELS=4 -> the N-channel downmix branch runs and the loop completes."""
|
|
self._drive(probs=[0.05] * 12, vad_sensitivity=3, channels=4, chunk_bytes=1024 * 4 * 2)
|
|
|
|
def test_resample_branch_runs_at_48khz(self):
|
|
"""rate=48000 -> the resample branch in _record_audio runs."""
|
|
self._drive(probs=[0.05] * 12, vad_sensitivity=3, rate=48000)
|
|
|
|
|
|
class TestRecordAudioAmplitudeFallback(unittest.TestCase):
|
|
"""When onnxruntime is unavailable, _record_audio falls back to an amplitude
|
|
threshold. These tests drive that path with `_silero_vad = None`.
|
|
"""
|
|
|
|
def setUp(self):
|
|
self.mgr = _make_manager()
|
|
self.mgr.state = RecognitionState.LISTENING
|
|
self.mgr.should_record = True
|
|
self.mgr.audio_buffer = []
|
|
self.mgr.silence_timeout = 0.5
|
|
self.mgr.vad_sensitivity = 3
|
|
self.mgr._silero_vad = None
|
|
|
|
self.enqueued = []
|
|
self.enqueued_gates: list[Optional[_ReleaseGate]] = []
|
|
|
|
def enqueue_segment(
|
|
audio_buffer: list[bytes],
|
|
release_gate: Optional[_ReleaseGate] = None,
|
|
) -> None:
|
|
self.enqueued.append(list(audio_buffer))
|
|
self.enqueued_gates.append(release_gate)
|
|
|
|
self.mgr._enqueue_audio_segment = enqueue_segment
|
|
|
|
def _drive(self, payload, max_iters):
|
|
stream = MagicMock()
|
|
counter = {"n": 0}
|
|
|
|
def _read(*a, **kw):
|
|
counter["n"] += 1
|
|
if counter["n"] >= max_iters:
|
|
self.mgr.should_record = False
|
|
return payload
|
|
|
|
stream.get_read_available.return_value = 1024
|
|
stream.read.side_effect = _read
|
|
pyaudio_mod, _ = _make_pyaudio_module(stream)
|
|
with (
|
|
patch.dict(sys.modules, {"pyaudio": pyaudio_mod, "numpy": np}),
|
|
patch(
|
|
"vocalinux.audio.capture._get_supported_channels",
|
|
return_value=1,
|
|
),
|
|
patch(
|
|
"vocalinux.audio.capture._get_supported_sample_rate",
|
|
return_value=16000,
|
|
),
|
|
):
|
|
self.mgr._record_audio()
|
|
|
|
def test_silence_drops_buffer(self):
|
|
"""All-zero samples -> volume below threshold -> no transcription segment."""
|
|
self._drive(payload=b"\x00" * (1024 * 2), max_iters=20)
|
|
self.assertEqual(len(self.enqueued), 0)
|
|
|
|
def test_invalid_sensitivity_uses_default_amplitude_threshold(self):
|
|
"""Bad sensitivity values should fall back to the default amplitude threshold."""
|
|
self.mgr.vad_sensitivity = "bad"
|
|
self._drive(payload=b"\x00" * (1024 * 2), max_iters=20)
|
|
self.assertEqual(len(self.enqueued), 0)
|
|
|
|
def test_loud_audio_is_speech(self):
|
|
"""Loud samples -> volume above threshold -> speech, no flush."""
|
|
loud = (np.full(1024, 20000, dtype=np.int16)).tobytes()
|
|
self._drive(payload=loud, max_iters=20)
|
|
self.assertEqual(len(self.enqueued), 0)
|
|
|
|
|
|
class TestAudioLevelCallback(unittest.TestCase):
|
|
"""The audio-level callback list is iterated every chunk with normalized
|
|
level in [0, 100]. Verify a registered callback receives values.
|
|
"""
|
|
|
|
def test_callback_receives_levels(self):
|
|
mgr = _make_manager()
|
|
mgr.state = RecognitionState.LISTENING
|
|
mgr.should_record = True
|
|
mgr.audio_buffer = []
|
|
mgr.silence_timeout = 0.5
|
|
mgr.vad_sensitivity = 3
|
|
mgr._silero_vad = None
|
|
mgr._enqueue_audio_segment = MagicMock()
|
|
|
|
levels = []
|
|
mgr._audio_level_callbacks = [levels.append]
|
|
|
|
stream = MagicMock()
|
|
counter = {"n": 0}
|
|
|
|
def _read(*a, **kw):
|
|
counter["n"] += 1
|
|
if counter["n"] >= 5:
|
|
mgr.should_record = False
|
|
return b"\x00" * (1024 * 2)
|
|
|
|
stream.get_read_available.return_value = 1024
|
|
stream.read.side_effect = _read
|
|
pyaudio_mod, _ = _make_pyaudio_module(stream)
|
|
with (
|
|
patch.dict(sys.modules, {"pyaudio": pyaudio_mod, "numpy": np}),
|
|
patch(
|
|
"vocalinux.audio.capture._get_supported_channels",
|
|
return_value=1,
|
|
),
|
|
patch(
|
|
"vocalinux.audio.capture._get_supported_sample_rate",
|
|
return_value=16000,
|
|
),
|
|
):
|
|
mgr._record_audio()
|
|
|
|
self.assertGreaterEqual(len(levels), 1)
|
|
for v in levels:
|
|
self.assertGreaterEqual(v, 0.0)
|
|
self.assertLessEqual(v, 100.0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|