fix(recognition): add thread safety for model access to prevent crash (#216)

Fixes #215

The crash (segfault at ffffffff in libstdc++.so.6) was caused by a race
condition between the transcription thread and model reconfiguration:

1. When recording finishes, `_transcribe_with_whispercpp()` accesses
   `self.model.transcribe()` which is a C++ object via pywhispercpp
2. If settings change simultaneously, `reconfigure()` sets `self.model = None`
3. The transcription thread then accesses null/freed memory → segfault

This fix adds a `_model_lock` to protect concurrent access to:
- `self.model` (Whisper and whisper.cpp engines)
- `self.recognizer` (VOSK engine)

The lock is now used in:
- `_transcribe_with_whisper()` - protects model.transcribe()
- `_transcribe_with_whispercpp()` - protects model.transcribe()
- `_process_final_buffer()` - protects recognizer for VOSK
- `reconfigure()` - protects model reinitialization

Each transcription method also checks if model/recognizer is None before
proceeding, returning an empty result gracefully if reconfiguration is in
progress.
This commit is contained in:
Jatin K Malik
2026-02-17 22:30:30 -08:00
committed by GitHub
parent 0da4e74005
commit 13991e16e6
@@ -352,6 +352,7 @@ class SpeechRecognitionManager:
self.should_record = False
self.audio_buffer = []
self._buffer_lock = threading.Lock() # Thread safety for audio_buffer
self._model_lock = threading.Lock() # Thread safety for model/recognizer access
# Reliability improvements - Issue #92
self._max_buffer_size = 5000 # Maximum number of audio chunks in buffer
@@ -526,28 +527,35 @@ class SpeechRecognitionManager:
duration = len(audio_float) / 16000.0 # 16kHz sample rate
logger.debug(f"Transcribing audio: {duration:.2f} seconds")
# Determine if we should use fp16 (only on CUDA)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
import torch
use_fp16 = self.model.device != torch.device("cpu")
# Lock model access to prevent race condition with reconfigure
with self._model_lock:
# Check if model is still valid
if self.model is None:
logger.warning("Model is None during transcription, returning empty result")
return ""
lang = self.language
if self.language == "en-us":
lang = "en"
elif self.language == "auto":
lang = None # Auto-detect
# Determine if we should use fp16 (only on CUDA)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
import torch
use_fp16 = self.model.device != torch.device("cpu")
# Transcribe with Whisper (handles variable length audio automatically)
result = self.model.transcribe(
audio_float,
language=lang,
task="transcribe",
verbose=False,
temperature=0.0, # Greedy decoding for consistency
no_speech_threshold=0.6,
fp16=use_fp16, # Explicitly set to avoid warning on CPU
)
lang = self.language
if self.language == "en-us":
lang = "en"
elif self.language == "auto":
lang = None # Auto-detect
# Transcribe with Whisper (handles variable length audio automatically)
result = self.model.transcribe(
audio_float,
language=lang,
task="transcribe",
verbose=False,
temperature=0.0, # Greedy decoding for consistency
no_speech_threshold=0.6,
fp16=use_fp16, # Explicitly set to avoid warning on CPU
)
text = result.get("text", "").strip()
@@ -699,11 +707,20 @@ class SpeechRecognitionManager:
logger.debug(f"whisper.cpp using language: {lang or 'auto-detect'}")
# Transcribe with whisper.cpp
# pywhispercpp expects audio as numpy array
transcribe_start = time.time()
segments = self.model.transcribe(audio_float, language=lang)
transcribe_duration = time.time() - transcribe_start
# Lock model access to prevent race condition with reconfigure
# This is critical because self.model is a C++ object via pywhispercpp
# and accessing it while reconfigure() sets it to None causes a segfault
with self._model_lock:
# Check if model is still valid
if self.model is None:
logger.warning("Model is None during transcription, returning empty result")
return ""
# Transcribe with whisper.cpp
# pywhispercpp expects audio as numpy array
transcribe_start = time.time()
segments = self.model.transcribe(audio_float, language=lang)
transcribe_duration = time.time() - transcribe_start
# Extract text from segments
text_parts = []
@@ -1535,11 +1552,17 @@ class SpeechRecognitionManager:
return
if self.engine == "vosk":
for data in self.audio_buffer:
self.recognizer.AcceptWaveform(data)
# Lock recognizer access to prevent race condition with reconfigure
with self._model_lock:
# Check if recognizer is still valid
if self.recognizer is None:
logger.warning("Recognizer is None during processing, returning empty result")
return
for data in self.audio_buffer:
self.recognizer.AcceptWaveform(data)
result = json.loads(self.recognizer.FinalResult())
text = result.get("text", "")
result = json.loads(self.recognizer.FinalResult())
text = result.get("text", "")
elif self.engine == "whisper":
text = self._transcribe_with_whisper(self.audio_buffer)
@@ -1632,26 +1655,30 @@ class SpeechRecognitionManager:
# When reconfiguring from UI, allow downloads
old_defer = self._defer_download
self._defer_download = not force_download
# Release old resources explicitly if necessary (Python's GC might handle it)
self.model = None
self.recognizer = None
try:
if self.engine == "vosk":
self._init_vosk()
elif self.engine == "whisper":
self._init_whisper()
elif self.engine == "whisper_cpp":
self._init_whispercpp()
else:
raise ValueError(f"Unsupported engine during reconfigure: {self.engine}")
logger.info("Speech engine re-initialized successfully.")
except Exception as e:
logger.error(f"Failed to re-initialize speech engine: {e}", exc_info=True)
self._update_state(RecognitionState.ERROR)
# Re-raise or handle appropriately
raise
finally:
self._defer_download = old_defer
# Lock model access during reinitialization to prevent race condition
# with transcription threads that may be using the model/recognizer
with self._model_lock:
# Release old resources explicitly if necessary (Python's GC might handle it)
self.model = None
self.recognizer = None
try:
if self.engine == "vosk":
self._init_vosk()
elif self.engine == "whisper":
self._init_whisper()
elif self.engine == "whisper_cpp":
self._init_whispercpp()
else:
raise ValueError(f"Unsupported engine during reconfigure: {self.engine}")
logger.info("Speech engine re-initialized successfully.")
except Exception as e:
logger.error(f"Failed to re-initialize speech engine: {e}", exc_info=True)
self._update_state(RecognitionState.ERROR)
# Re-raise or handle appropriately
raise
finally:
self._defer_download = old_defer
else:
# If only VOSK params changed, just log it
logger.info("Applied VAD/silence timeout changes.")