Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ cd vocalinux
# Or pick the engine up front
./install.sh --engine=whisper_cpp # whisper.cpp (default, GPU-accelerated)
./install.sh --engine=vosk # lightweight VOSK
./install.sh --engine=parakeet --auto # Parakeet (25 European languages, CPU)
```

The installer handles everything: system dependencies, Python environment, speech models, and desktop integration.
Expand Down
36 changes: 35 additions & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,7 @@ while [[ $# -gt 0 ]]; do
echo "Options:"
echo " --interactive, -i Force interactive mode (default)"
echo " --auto Non-interactive automatic installation"
echo " --engine=NAME Speech engine: whisper_cpp (default), whisper, vosk, remote_api"
echo " --engine=NAME Speech engine: whisper_cpp (default), whisper, vosk, parakeet, remote_api"
echo " --dev Install in development mode with all dev dependencies"
echo " --test Run tests after installation"
echo " --venv-dir=PATH Specify custom virtual environment directory"
Expand Down Expand Up @@ -2913,6 +2913,7 @@ engine_import_module() {
vosk) echo "vosk" ;;
whisper) echo "whisper" ;;
whisper_cpp) echo "pywhispercpp.model" ;;
parakeet) echo "sherpa_onnx" ;;
*) echo "" ;;
esac
}
Expand All @@ -2922,6 +2923,7 @@ engine_pip_name() {
vosk) echo "vosk" ;;
whisper) echo "openai-whisper" ;;
whisper_cpp) echo "pywhispercpp" ;;
parakeet) echo "sherpa-onnx" ;;
*) echo "" ;;
esac
}
Expand Down Expand Up @@ -3512,6 +3514,34 @@ VOSK_CONFIG
fi
;;

parakeet)
print_info "Installing Parakeet (sherpa-onnx)..."
print_info "Parakeet runs NVIDIA NeMo ASR models on CPU."

# sherpa-onnx is an optional extra; install it alongside the base package
pip_install_extras_skip_pygobject "$PIP_LOG_FILE" parakeet || {
print_error "Failed to install the parakeet engine"
return 1
}

# config_manager merges the remaining defaults, but a file with no
# shortcuts section reads as a pre-push-to-talk config and gets
# pinned back to ctrl+ctrl/toggle, so seed that section too.
mkdir -p "$CONFIG_DIR"
if [ ! -f "$CONFIG_DIR/config.json" ]; then
cat > "$CONFIG_DIR/config.json" << 'PARAKEET_CONFIG'
{
"shortcuts": {
"toggle_recognition": "right_alt+right_alt",
"mode": "push_to_talk"
}
}
PARAKEET_CONFIG
fi
set_configured_engine "$CONFIG_DIR/config.json" parakeet ||
print_warning "Could not point $CONFIG_DIR/config.json at the parakeet engine."
;;

remote_api)
print_info "Setting up Remote API engine..."
print_info ""
Expand Down Expand Up @@ -4593,6 +4623,10 @@ EOF
ENGINE_DISPLAY_NAME="VOSK"
BACKEND_INFO="Lightweight"
;;
parakeet)
ENGINE_DISPLAY_NAME="Parakeet"
BACKEND_INFO="CPU"
;;
remote_api)
ENGINE_DISPLAY_NAME="Remote API"
BACKEND_INFO="Network (offloaded to remote server)"
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ whisper = [
"torch>=2.0.0",
"torchaudio>=2.0.0",
]
parakeet = [
"sherpa-onnx>=1.12.0",
]
vad = [
"onnxruntime>=1.16.0",
]
Expand Down
2 changes: 1 addition & 1 deletion src/vocalinux/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ def parse_arguments():
parser.add_argument(
"--engine",
type=str,
choices=["vosk", "whisper", "whisper_cpp", "remote_api"],
choices=["vosk", "whisper", "whisper_cpp", "parakeet", "remote_api"],
help="Speech recognition engine to use (whisper_cpp recommended for best performance)",
)
parser.add_argument("--wayland", action="store_true", help="Force Wayland compatibility mode")
Expand Down
149 changes: 149 additions & 0 deletions src/vocalinux/speech_recognition/recognition_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

from ..common_types import RecognitionState
from ..ui.audio_feedback import play_error_sound, play_start_sound, play_stop_sound
from ..utils import parakeet_model_info as parakeet
from ..utils.host_process import host_env
from ..utils.model_checksums import (
ChecksumError,
Expand Down Expand Up @@ -1086,6 +1087,8 @@ def __init__(
self._init_whisper()
elif engine == "whisper_cpp":
self._init_whispercpp()
elif engine == "parakeet":
self._init_parakeet()
elif engine == "remote_api":
self._init_remote_api()
else:
Expand Down Expand Up @@ -1763,6 +1766,145 @@ def _transcribe_with_whispercpp(self, audio_buffer: list[bytes]) -> str:
logger.error(f"Error in whisper.cpp transcription: {e} ({audio_info})", exc_info=True)
return ""

def _download_parakeet_model(self):
"""Download the Parakeet model files with progress tracking."""
import requests

self._download_cancelled = False

model_dir = parakeet.get_model_path(self.model_size)
os.makedirs(model_dir, exist_ok=True)
logger.info(f"Downloading Parakeet '{self.model_size}' model to {model_dir}")

# _stream_model_download reports 0..1 per file, so remap each file into
# its own slice of the bundle: the bar advances once across the whole
# download instead of restarting for each of the four files.
outer_callback = self._download_progress_callback
total_files = len(parakeet.MODEL_FILES)

def file_progress(index, name):
def report(fraction, speed, status):
if outer_callback:
outer_callback(
(index + fraction) / total_files,
speed,
f"{name} ({index + 1}/{total_files}) - {status}",
)

return report

temp_file = None
try:
for index, filename in enumerate(parakeet.MODEL_FILES):
dest_path = os.path.join(model_dir, filename)
if os.path.exists(dest_path):
continue
temp_file = dest_path + ".tmp"
url = parakeet.get_model_file_url(self.model_size, filename)
self._download_progress_callback = file_progress(index, filename)
self._stream_model_download(url, temp_file)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
os.rename(temp_file, dest_path)
Comment thread
AlexThunder1989 marked this conversation as resolved.
Comment thread
AlexThunder1989 marked this conversation as resolved.
temp_file = None

# RequestException=Exception under the test mocks; do not catch
# Timeout separately (it is not a real exception type there).
except requests.exceptions.RequestException as e:
logger.error(f"Failed to download the Parakeet model: {e}")
if temp_file and os.path.exists(temp_file):
os.remove(temp_file)
msg = str(e).lower()
if "timeout" in msg or type(e).__name__ == "Timeout":
raise RuntimeError(
"Model download timed out (Hugging Face may be slow or unavailable). "
"Check your network and try again."
) from e
raise RuntimeError(f"Failed to download Parakeet model: {e}") from e
except (OSError, RuntimeError, ValueError) as e:
logger.error(f"An error occurred during Parakeet model download: {e}")
if temp_file and os.path.exists(temp_file):
os.remove(temp_file)
raise
finally:
self._download_progress_callback = outer_callback

logger.info("Parakeet model downloaded successfully")
if self._download_progress_callback:
self._download_progress_callback(1.0, 0, "Complete!")

def _init_parakeet(self):
"""Initialize the Parakeet speech recognition engine."""
try:
import sherpa_onnx

# Validate model size for Parakeet
valid_models = list(parakeet.PARAKEET_MODEL_INFO.keys())
if self.model_size not in valid_models:
logger.warning(
f"Model size '{self.model_size}' not valid for Parakeet. "
f"Valid options: {valid_models}. Using '{parakeet.RECOMMENDED_MODEL}' instead."
)
self.model_size = parakeet.RECOMMENDED_MODEL

model_dir = parakeet.get_model_path(self.model_size)

if not parakeet.is_model_downloaded(self.model_size):
if self._defer_download:
logger.info(
f"Parakeet model '{self.model_size}' not found at {model_dir}. "
"Will download when needed."
)
self._model_initialized = False
return # Don't block startup
else:
logger.info(f"Downloading Parakeet '{self.model_size}' model...")
self._download_parakeet_model()

# NOTE: do not take _model_lock here. It is a non-reentrant Lock and
# reconfigure()/reinitialize_after_resume() already hold it when they
# call this, so acquiring it would deadlock the caller.
self.model = None
self.model = sherpa_onnx.OfflineRecognizer.from_transducer(
encoder=os.path.join(model_dir, "encoder.int8.onnx"),
decoder=os.path.join(model_dir, "decoder.int8.onnx"),
joiner=os.path.join(model_dir, "joiner.int8.onnx"),
tokens=os.path.join(model_dir, "tokens.txt"),
model_type="nemo_transducer",
feature_dim=128,
num_threads=4,
)
self._model_initialized = True
logger.info(f"Loaded Parakeet model from {model_dir}")

except ImportError as e:
logger.error(f"Failed to import sherpa-onnx: {e}")
logger.error("Please install it with 'pip install sherpa-onnx'")
self.state = RecognitionState.ERROR
raise
except (FileNotFoundError, RuntimeError, OSError) as e:
logger.error(f"Failed to initialize Parakeet engine: {e}", exc_info=True)
self.state = RecognitionState.ERROR
raise

def _transcribe_with_parakeet(self, audio_buffer: list[bytes]) -> str:
"""Transcribe 16-bit PCM chunks (16kHz) with Parakeet."""
import numpy as np

if not audio_buffer:
return ""

audio_data = np.frombuffer(b"".join(audio_buffer), dtype=np.int16)
audio_float = audio_data.astype(np.float32) / 32768.0

# Lock model access to prevent a race with reconfigure() setting self.model to None
with self._model_lock:
if self.model is None:
logger.warning("Model is None during transcription, returning empty result")
return ""
stream = self.model.create_stream()
stream.accept_waveform(16000, audio_float)
self.model.decode_stream(stream)
return stream.result.text.strip()

def _init_remote_api(self):
"""Initialize remote API speech recognition engine.

Expand Down Expand Up @@ -3017,6 +3159,9 @@ def _process_audio_buffer(self, audio_buffer: list[bytes]):
elif self.engine == "whisper_cpp":
text = self._transcribe_with_whispercpp(audio_buffer)

elif self.engine == "parakeet":
text = self._transcribe_with_parakeet(audio_buffer)

elif self.engine == "remote_api":
# Snapshot the HTTP session under lock to prevent race with
# reconfigure() / reinitialize_after_resume() which close/recreate
Expand Down Expand Up @@ -3289,6 +3434,8 @@ def reconfigure(
self._init_whisper()
elif self.engine == "whisper_cpp":
self._init_whispercpp()
elif self.engine == "parakeet":
self._init_parakeet()
elif self.engine == "remote_api":
self._init_remote_api()
else:
Expand Down Expand Up @@ -3516,6 +3663,8 @@ def reinitialize_after_resume(self):
self._init_whisper()
elif self.engine == "whisper_cpp":
self._init_whispercpp()
elif self.engine == "parakeet":
self._init_parakeet()
elif self.engine == "remote_api":
self._init_remote_api()
else:
Expand Down
1 change: 1 addition & 0 deletions src/vocalinux/ui/config_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ def normalize_sound_effect_tone(tone: Any) -> str:
"vosk_model_size": "small", # Default model for VOSK engine
"whisper_model_size": "tiny", # Default model for Whisper engine
"whisper_cpp_model_size": "tiny", # Default model for whisper.cpp engine
"parakeet_model_size": "v3-european", # Parakeet TDT 0.6B v3 (25 European languages)
"vad_sensitivity": 3, # Voice Activity Detection sensitivity (1-5)
"silence_timeout": 2.0, # Seconds of silence before stopping
"stop_sound_guard_ms": 200, # Small tail trim to avoid the stop sound without clipping speech
Expand Down
Loading
Loading