diff --git a/install.sh b/install.sh
index 9f0cf2c..088ba7f 100755
--- a/install.sh
+++ b/install.sh
@@ -645,7 +645,8 @@ install_python_package() {
# Function to verify package installation
verify_package_installed() {
local PKG_NAME="vocalinux"
- python -c "import $PKG_NAME" 2>/dev/null
+ # Use venv python and set GI_TYPELIB_PATH for PyGObject
+ GI_TYPELIB_PATH=/usr/lib/girepository-1.0 "$VENV_DIR/bin/python" -c "import $PKG_NAME" 2>/dev/null
return $?
}
@@ -782,18 +783,34 @@ VOSK_CONFIG
# Clean up log file if installation was successful
rm -rf "$PIP_LOG_DIR"
- # Create symlink in ~/.local/bin for easy access
- if [[ "$CLEANUP_ON_EXIT" == "yes" ]]; then
- mkdir -p "$HOME/.local/bin"
- ln -sf "$VENV_DIR/bin/vocalinux" "$HOME/.local/bin/vocalinux"
- print_info "Created symlink: ~/.local/bin/vocalinux"
+ # Create wrapper scripts in ~/.local/bin for easy access
+ mkdir -p "$HOME/.local/bin"
- # Check if ~/.local/bin is in PATH
- if [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then
- print_warning "~/.local/bin is not in your PATH"
- print_info "Add this line to your ~/.bashrc or ~/.zshrc:"
- print_info ' export PATH="$HOME/.local/bin:$PATH"'
- fi
+ # Create vocalinux wrapper script
+ cat > "$HOME/.local/bin/vocalinux" << WRAPPER_EOF
+#!/bin/bash
+# Wrapper script for Vocalinux that sets required environment variables
+export GI_TYPELIB_PATH=/usr/lib/girepository-1.0
+exec "$VENV_DIR/bin/vocalinux" "\$@"
+WRAPPER_EOF
+ chmod +x "$HOME/.local/bin/vocalinux"
+ print_info "Created wrapper: ~/.local/bin/vocalinux"
+
+ # Create vocalinux-gui wrapper script
+ cat > "$HOME/.local/bin/vocalinux-gui" << WRAPPER_EOF
+#!/bin/bash
+# Wrapper script for Vocalinux GUI that sets required environment variables
+export GI_TYPELIB_PATH=/usr/lib/girepository-1.0
+exec "$VENV_DIR/bin/vocalinux-gui" "\$@"
+WRAPPER_EOF
+ chmod +x "$HOME/.local/bin/vocalinux-gui"
+ print_info "Created wrapper: ~/.local/bin/vocalinux-gui"
+
+ # Check if ~/.local/bin is in PATH
+ if [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then
+ print_warning "~/.local/bin is not in your PATH"
+ print_info "Add this line to your ~/.bashrc or ~/.zshrc:"
+ print_info ' export PATH="$HOME/.local/bin:$PATH"'
fi
return 0
@@ -1004,16 +1021,17 @@ install_desktop_entry() {
return 1
}
- # Update the desktop entry to use the venv
- VENV_SCRIPT_PATH="$(realpath $VENV_DIR/bin/vocalinux)"
- if [ ! -f "$VENV_SCRIPT_PATH" ]; then
- print_warning "Vocalinux script not found at $VENV_SCRIPT_PATH"
+ # Update the desktop entry to use the wrapper script with GI_TYPELIB_PATH
+ WRAPPER_SCRIPT="$HOME/.local/bin/vocalinux-gui"
+ if [ ! -f "$WRAPPER_SCRIPT" ]; then
+ print_warning "Wrapper script not found at $WRAPPER_SCRIPT"
print_warning "Desktop entry may not work correctly"
else
- sed -i "s|^Exec=vocalinux|Exec=$VENV_SCRIPT_PATH|" "$DESKTOP_DIR/vocalinux.desktop" || {
+ # Update Exec line to include GI_TYPELIB_PATH for PyGObject
+ sed -i "s|^Exec=vocalinux|Exec=env GI_TYPELIB_PATH=/usr/lib/girepository-1.0 $WRAPPER_SCRIPT|" "$DESKTOP_DIR/vocalinux.desktop" || {
print_warning "Failed to update desktop entry path"
}
- print_info "Updated desktop entry to use virtual environment"
+ print_info "Updated desktop entry to use wrapper script with GI_TYPELIB_PATH"
fi
# Make desktop entry executable
diff --git a/src/vocalinux/main.py b/src/vocalinux/main.py
index c5c8178..dffccf9 100644
--- a/src/vocalinux/main.py
+++ b/src/vocalinux/main.py
@@ -33,8 +33,23 @@ def parse_arguments():
parser.add_argument(
"--language",
type=str,
- choices=["en-us", "fr", "de", "ru"],
- help="Speech recognition language (en-us, fr, de, ru)",
+ choices=[
+ "auto",
+ "en-us",
+ "en-in",
+ "hi",
+ "es",
+ "fr",
+ "de",
+ "it",
+ "pt",
+ "ru",
+ "zh",
+ "ja",
+ "ko",
+ "ar",
+ ],
+ help="Speech recognition language (auto for auto-detect, en-us, hi, es, fr, de, it, pt, ru, zh, etc.)",
)
parser.add_argument(
"--engine",
diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py
index 4acf4ed..1b5c993 100644
--- a/src/vocalinux/speech_recognition/recognition_manager.py
+++ b/src/vocalinux/speech_recognition/recognition_manager.py
@@ -301,10 +301,13 @@ class SpeechRecognitionManager:
def _init_vosk(self):
"""Initialize the VOSK speech recognition engine."""
+ # VOSK doesn't support auto-detect, so fall back to en-us for "auto"
+ vosk_language = "en-us" if self.language == "auto" else self.language
+
self.vosk_model_map = {
- "small": VOSK_MODEL_INFO["small"]["languages"].get(self.language),
- "medium": VOSK_MODEL_INFO["medium"]["languages"].get(self.language),
- "large": VOSK_MODEL_INFO["large"]["languages"].get(self.language),
+ "small": VOSK_MODEL_INFO["small"]["languages"].get(vosk_language),
+ "medium": VOSK_MODEL_INFO["medium"]["languages"].get(vosk_language),
+ "large": VOSK_MODEL_INFO["large"]["languages"].get(vosk_language),
}
try:
@@ -452,6 +455,8 @@ class SpeechRecognitionManager:
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(
@@ -1140,6 +1145,7 @@ class SpeechRecognitionManager:
self,
engine: Optional[str] = None,
model_size: Optional[str] = None,
+ language: Optional[str] = None,
vad_sensitivity: Optional[int] = None,
silence_timeout: Optional[float] = None,
audio_device_index: Optional[int] = None,
@@ -1152,13 +1158,14 @@ class SpeechRecognitionManager:
Args:
engine: The new speech recognition engine ("vosk" or "whisper").
model_size: The new model size.
+ language: The new language code (e.g., "en-us", "hi", "auto").
vad_sensitivity: New VAD sensitivity (for VOSK).
silence_timeout: New silence timeout (for VOSK).
audio_device_index: Audio input device index (None for default, -1 to clear).
force_download: If True, download missing models (default: True for UI-triggered reconfigures).
"""
logger.info(
- f"Reconfiguring speech engine. New settings: engine={engine}, model_size={model_size}, vad={vad_sensitivity}, silence={silence_timeout}, audio_device={audio_device_index}"
+ f"Reconfiguring speech engine. New settings: engine={engine}, model_size={model_size}, language={language}, vad={vad_sensitivity}, silence={silence_timeout}, audio_device={audio_device_index}"
)
restart_needed = False
@@ -1170,6 +1177,13 @@ class SpeechRecognitionManager:
self.model_size = model_size
restart_needed = True
+ # Language change requires restart for both engines
+ # Whisper needs to know the language for transcription
+ # VOSK needs to load a different model for the new language
+ if language is not None and language != self.language:
+ self.language = language
+ restart_needed = True
+
# Update VOSK specific params if provided
if vad_sensitivity is not None:
self.vad_sensitivity = max(1, min(5, int(vad_sensitivity)))
diff --git a/src/vocalinux/ui/config_manager.py b/src/vocalinux/ui/config_manager.py
index 83185e1..a37fe96 100644
--- a/src/vocalinux/ui/config_manager.py
+++ b/src/vocalinux/ui/config_manager.py
@@ -19,7 +19,7 @@ CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json")
DEFAULT_CONFIG = {
"speech_recognition": { # Changed section name
"engine": "whisper", # "vosk" or "whisper" - whisper is default for better accuracy
- "language": "en-us", # "en-us", "fr", "de" or "ru"
+ "language": "auto", # Auto-detect language (Whisper only)
"model_size": "tiny", # Current model size (for backward compatibility)
"vosk_model_size": "small", # Default model for VOSK engine
"whisper_model_size": "tiny", # Default model for Whisper engine
diff --git a/src/vocalinux/ui/logging_manager.py b/src/vocalinux/ui/logging_manager.py
index 734d0ea..7215bdc 100644
--- a/src/vocalinux/ui/logging_manager.py
+++ b/src/vocalinux/ui/logging_manager.py
@@ -246,6 +246,8 @@ class LoggingHandler(logging.Handler):
def __init__(self, logging_manager: LoggingManager):
super().__init__()
self.logging_manager = logging_manager
+ # Track depth to prevent recursive logging
+ self._emitting = False
def emit(self, record: logging.LogRecord):
"""
@@ -254,7 +256,13 @@ class LoggingHandler(logging.Handler):
Args:
record: The logging record to emit
"""
+ # Prevent recursive calls
+ if self._emitting:
+ return
+
try:
+ self._emitting = True
+
# Create our custom log record
log_record = LogRecord(
timestamp=datetime.fromtimestamp(record.created),
@@ -270,6 +278,8 @@ class LoggingHandler(logging.Handler):
except Exception:
# Don't let logging errors break the application
self.handleError(record)
+ finally:
+ self._emitting = False
# Global logging manager instance
diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py
index 83e1a3e..3494b0d 100644
--- a/src/vocalinux/ui/settings_dialog.py
+++ b/src/vocalinux/ui/settings_dialog.py
@@ -18,7 +18,7 @@ gi.require_version("Gtk", "3.0")
from gi.repository import GLib, Gtk # noqa: E402
from ..common_types import RecognitionState # noqa: E402
-from ..utils.vosk_model_info import VOSK_MODEL_INFO
+from ..utils.vosk_model_info import SUPPORTED_LANGUAGES, VOSK_MODEL_INFO
# Avoid circular imports for type checking
if TYPE_CHECKING:
@@ -125,8 +125,9 @@ def _is_vosk_model_downloaded(size: str, language: str) -> bool:
if size not in VOSK_MODEL_INFO:
return False
- if language not in VOSK_MODEL_INFO[size]["languages"]:
- language = "en-us" # Fallback to en-us if language not found
+ # Auto-detect is not supported by VOSK, fall back to en-us
+ if language == "auto" or language not in VOSK_MODEL_INFO[size]["languages"]:
+ language = "en-us"
model_name = VOSK_MODEL_INFO[size]["languages"][language]
@@ -295,6 +296,11 @@ class SettingsDialog(Gtk.Dialog):
self._test_active = False
self._test_result = ""
self._initializing = True # Flag to prevent auto-apply during initialization
+ self._populating_models = False # Flag to prevent model change handler during population
+ self._processing_language_change = (
+ False # Flag to prevent recursive language change handling
+ )
+ self._applying_settings = False # Flag to prevent recursive settings application
self.add_buttons(
Gtk.STOCK_CLOSE,
@@ -391,6 +397,19 @@ class SettingsDialog(Gtk.Dialog):
self.grid.attach(self.model_combo, 1, row, 1, 1)
row += 1
+ # Language Selection
+ self.grid.attach(Gtk.Label(label="Language:", halign=Gtk.Align.START), 0, row, 1, 1)
+ self.language_combo = Gtk.ComboBoxText()
+ self.language_combo.set_tooltip_text("Select primary language for speech recognition")
+ self.grid.attach(self.language_combo, 1, row, 1, 1)
+ row += 1
+
+ # Language warning label (shown for auto/unsupported)
+ self.language_warning = Gtk.Label(label="", use_markup=True, halign=Gtk.Align.START)
+ self.language_warning.set_margin_bottom(5)
+ self.grid.attach(self.language_warning, 0, row, 2, 1)
+ row += 1
+
# Model legend (applies to both engines)
model_legend = Gtk.Label(
label="✓ = Downloaded ↓ = Will download ★ = Recommended",
@@ -437,6 +456,9 @@ class SettingsDialog(Gtk.Dialog):
# Add model change handler
self.model_combo.connect("changed", self._on_model_changed)
+ # Add language change handler
+ self.language_combo.connect("changed", self._on_language_changed)
+
# ==================== SEPARATOR ====================
self.grid.attach(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), 0, row, 2, 1)
row += 1
@@ -555,6 +577,16 @@ class SettingsDialog(Gtk.Dialog):
# Populate model options for the selected engine
self._populate_model_options()
+ # Populate language options
+ self._populate_language_options()
+ # Set the current language from settings
+ if self.language:
+ if not self.language_combo.set_active_id(self.language):
+ # Fallback to auto if language not found
+ logger.warning(f"Language '{self.language}' not found in options, using auto")
+ self.language_combo.set_active_id("auto")
+ self.language = "auto"
+
# Set non-dependent widgets directly
self.vad_spin.set_value(self.current_vad)
self.silence_spin.set_value(self.current_silence)
@@ -598,52 +630,59 @@ class SettingsDialog(Gtk.Dialog):
def _populate_model_options(self):
"""Populate model options based on the current engine selection."""
- # Clear existing items
- self.model_combo.remove_all()
+ # Set flag to prevent model change handler from firing during population
+ self._populating_models = True
+ try:
+ # Clear existing items
+ self.model_combo.remove_all()
- # Get the current engine text and convert to lowercase for lookup
- engine_text = self.engine_combo.get_active_text()
- if not engine_text:
- logger.warning("No engine selected during model options population")
- return
+ # Get the current engine text and convert to lowercase for lookup
+ engine_text = self.engine_combo.get_active_text()
+ if not engine_text:
+ logger.warning("No engine selected during model options population")
+ return
- engine = engine_text.lower()
- logger.info(f"Populating model options for engine: {engine}")
+ engine = engine_text.lower()
+ logger.info(f"Populating model options for engine: {engine}")
- # Get the saved model size for THIS specific engine (not the generic one)
- saved_model_for_engine = self.config_manager.get_model_size_for_engine(engine)
- logger.info(f"Saved model for {engine}: {saved_model_for_engine}")
+ # Get the saved model size for THIS specific engine (not the generic one)
+ saved_model_for_engine = self.config_manager.get_model_size_for_engine(engine)
+ logger.info(f"Saved model for {engine}: {saved_model_for_engine}")
- # Track which models are downloaded and find smallest downloaded
- downloaded_models = []
- smallest_model = None
+ # Track which models are downloaded and find smallest downloaded
+ downloaded_models = []
+ smallest_model = None
- # Add model sizes for this engine
- if engine in ENGINE_MODELS:
- # Add all options for this engine
- for size in ENGINE_MODELS[engine]:
- if engine == "whisper" and size in WHISPER_MODEL_INFO:
- info = WHISPER_MODEL_INFO[size]
- is_downloaded = _is_whisper_model_downloaded(size)
- status = "✓" if is_downloaded else "↓"
- display_text = f"{size.capitalize()} ({_format_size(info['size_mb'])}) {status}"
- if is_downloaded:
- downloaded_models.append(size)
- if smallest_model is None:
- smallest_model = size
- elif engine == "vosk" and size in VOSK_MODEL_INFO:
- info = VOSK_MODEL_INFO[size]
- is_downloaded = _is_vosk_model_downloaded(size, self.language)
- status = "✓" if is_downloaded else "↓"
- display_text = f"{size.capitalize()} ({_format_size(info['size_mb'])}) {status}"
- if is_downloaded:
- downloaded_models.append(size)
- if smallest_model is None:
- smallest_model = size
- else:
- display_text = size.capitalize()
- # Use lowercase as ID, display text with info
- self.model_combo.append(size.capitalize(), display_text)
+ # Add model sizes for this engine
+ if engine in ENGINE_MODELS:
+ # Add all options for this engine
+ for size in ENGINE_MODELS[engine]:
+ if engine == "whisper" and size in WHISPER_MODEL_INFO:
+ info = WHISPER_MODEL_INFO[size]
+ is_downloaded = _is_whisper_model_downloaded(size)
+ status = "✓" if is_downloaded else "↓"
+ display_text = (
+ f"{size.capitalize()} ({_format_size(info['size_mb'])}) {status}"
+ )
+ if is_downloaded:
+ downloaded_models.append(size)
+ if smallest_model is None:
+ smallest_model = size
+ elif engine == "vosk" and size in VOSK_MODEL_INFO:
+ info = VOSK_MODEL_INFO[size]
+ is_downloaded = _is_vosk_model_downloaded(size, self.language)
+ status = "✓" if is_downloaded else "↓"
+ display_text = (
+ f"{size.capitalize()} ({_format_size(info['size_mb'])}) {status}"
+ )
+ if is_downloaded:
+ downloaded_models.append(size)
+ if smallest_model is None:
+ smallest_model = size
+ else:
+ display_text = size.capitalize()
+ # Use lowercase as ID, display text with info
+ self.model_combo.append(size.capitalize(), display_text)
# Determine which model to select:
# 1. If saved model for this engine is downloaded, use it
@@ -693,16 +732,46 @@ class SettingsDialog(Gtk.Dialog):
# Log final selection
logger.info(f"Final selected model: {self.model_combo.get_active_text()}")
+ finally:
+ # Clear flag to re-enable model change handler
+ self._populating_models = False
def _on_engine_changed(self, widget):
"""Handle changes in the selected engine."""
+ engine_text = self.engine_combo.get_active_text()
+ if not engine_text:
+ return
+
+ engine = engine_text.lower()
+
+ # Check if current language is supported by the new engine
+ current_lang = self.language_combo.get_active_id()
+ if current_lang:
+ if engine == "vosk" and (
+ current_lang == "auto" or not SUPPORTED_LANGUAGES.get(current_lang, {}).get("vosk")
+ ):
+ # Switching to VOSK with unsupported language, fall back to en-us
+ self.language = "en-us"
+ elif engine == "whisper" and not current_lang:
+ # No language selected, default to auto for Whisper
+ self.language = "auto"
+
self._populate_model_options()
+ self._populate_language_options()
+
+ # Set the active language in the combo after repopulating
+ # This will trigger _on_language_changed which will apply settings
+ self.language_combo.set_active_id(self.language)
+
self._update_engine_specific_ui()
self._update_whisper_info()
- self._auto_apply_settings()
def _on_model_changed(self, widget):
"""Handle changes in the selected model."""
+ # Don't process if we're populating models (prevents recursive calls)
+ if self._populating_models:
+ return
+
engine_text = self.engine_combo.get_active_text()
if engine_text:
engine = engine_text.lower()
@@ -720,84 +789,160 @@ class SettingsDialog(Gtk.Dialog):
"""Handle changes in silence timeout."""
self._auto_apply_settings()
+ def _populate_language_options(self):
+ """Populate language dropdown with supported languages."""
+ self.language_combo.remove_all()
+ engine = self.engine_combo.get_active_text()
+ if not engine:
+ return
+
+ engine = engine.lower()
+
+ for lang_code, lang_info in SUPPORTED_LANGUAGES.items():
+ display_text = lang_info["name"]
+
+ # Filter languages based on engine support
+ if engine == "vosk":
+ # VOSK: only show languages with available models (skip "auto" since VOSK doesn't support it)
+ has_model = lang_info["vosk"] is not None
+ if not has_model or lang_code == "auto":
+ continue # Skip languages not supported by VOSK
+ is_downloaded = _is_vosk_model_downloaded("small", lang_code)
+ display_text += " ✓" if is_downloaded else " ↓"
+ elif engine == "whisper":
+ # Whisper: show all languages, add warning for auto-detect
+ if lang_code == "auto":
+ display_text += " ⚠"
+ else:
+ continue # Unknown engine
+
+ self.language_combo.append(lang_code, display_text)
+
+ def _on_language_changed(self, widget):
+ """Handle language selection change."""
+ # Guard against recursive calls
+ if self._processing_language_change:
+ return
+
+ lang_code = self.language_combo.get_active_id()
+ if not lang_code:
+ return
+
+ engine = self.engine_combo.get_active_text()
+ if not engine:
+ return
+
+ self._processing_language_change = True
+ try:
+ engine = engine.lower()
+ lang_info = SUPPORTED_LANGUAGES.get(lang_code, {})
+
+ # Update warning label for auto-detect only
+ if lang_info.get("warning"):
+ self.language_warning.set_markup(
+ f"⚠ {lang_info['warning']}"
+ )
+ else:
+ self.language_warning.set_markup("")
+
+ # Update self.language for VOSK model download checks
+ self.language = lang_code
+
+ # Refresh model options to update download status indicators
+ self._populate_model_options()
+
+ self._auto_apply_settings()
+ finally:
+ self._processing_language_change = False
+
def _auto_apply_settings(self):
"""Automatically apply settings when changed."""
+ # Guard against recursive calls
+ if self._applying_settings:
+ return
+
if self._initializing:
return # Don't auto-apply during initialization
if self._test_active:
return # Don't apply during testing
- settings = self.get_selected_settings()
- engine = settings.get("engine", "vosk")
- model_name = settings.get("model_size", "small")
-
- # Check if model needs to be downloaded
- needs_download = False
- if engine == "whisper" and not _is_whisper_model_downloaded(model_name):
- needs_download = True
- model_info = WHISPER_MODEL_INFO.get(model_name, {"size_mb": 500})
- elif engine == "vosk" and not _is_vosk_model_downloaded(model_name, self.language):
- needs_download = True
- model_info = VOSK_MODEL_INFO.get(model_name, {"size_mb": 50})
-
- if needs_download:
- # Show download dialog for models that need downloading
- logger.info(f"Model {model_name} needs download, showing progress dialog")
- download_dialog = ModelDownloadDialog(
- self,
- model_name,
- model_info["size_mb"],
- engine=engine,
- language=self.language,
- )
-
- def progress_callback(fraction, speed, status):
- """Update UI with download progress."""
- GLib.idle_add(download_dialog.update_progress, fraction, speed, status)
-
- def download_and_apply():
- try:
- # Set up progress callback for downloads (both VOSK and Whisper)
- self.speech_engine.set_download_progress_callback(progress_callback)
-
- # Check for cancellation periodically
- def check_cancelled():
- if download_dialog.cancelled:
- self.speech_engine.cancel_download()
- return not download_dialog.cancelled
-
- # Start cancellation checker
- cancel_check_id = GLib.timeout_add(100, check_cancelled)
-
- try:
- self._apply_settings_internal(settings)
- GLib.idle_add(download_dialog.set_complete, True, "")
- # Refresh model list after download to update icons
- GLib.idle_add(self._populate_model_options)
- finally:
- GLib.source_remove(cancel_check_id)
- self.speech_engine.set_download_progress_callback(None)
-
- except Exception as e:
- error_msg = str(e)
- if "cancelled" in error_msg.lower():
- GLib.idle_add(download_dialog.set_complete, False, "Download cancelled")
- elif engine == "whisper" and "no module named" in error_msg.lower():
- GLib.idle_add(download_dialog.set_complete, False, "Whisper not installed")
- GLib.idle_add(self._show_whisper_install_dialog)
- else:
- GLib.idle_add(download_dialog.set_complete, False, error_msg[:100])
-
- threading.Thread(target=download_and_apply, daemon=True).start()
- download_dialog.run()
- download_dialog.destroy()
- return
-
- # Model already downloaded, just apply settings directly
- logger.info(f"Auto-applying settings: {settings}")
+ if self._populating_models:
+ return # Don't apply during model population
+ self._applying_settings = True
try:
+ settings = self.get_selected_settings()
+ engine = settings.get("engine", "vosk")
+ model_name = settings.get("model_size", "small")
+
+ # Check if model needs to be downloaded
+ needs_download = False
+ if engine == "whisper" and not _is_whisper_model_downloaded(model_name):
+ needs_download = True
+ model_info = WHISPER_MODEL_INFO.get(model_name, {"size_mb": 500})
+ elif engine == "vosk" and not _is_vosk_model_downloaded(model_name, self.language):
+ needs_download = True
+ model_info = VOSK_MODEL_INFO.get(model_name, {"size_mb": 50})
+
+ if needs_download:
+ # Show download dialog for models that need downloading
+ logger.info(f"Model {model_name} needs download, showing progress dialog")
+ download_dialog = ModelDownloadDialog(
+ self,
+ model_name,
+ model_info["size_mb"],
+ engine=engine,
+ language=self.language,
+ )
+
+ def progress_callback(fraction, speed, status):
+ """Update UI with download progress."""
+ GLib.idle_add(download_dialog.update_progress, fraction, speed, status)
+
+ def download_and_apply():
+ try:
+ # Set up progress callback for downloads (both VOSK and Whisper)
+ self.speech_engine.set_download_progress_callback(progress_callback)
+
+ # Check for cancellation periodically
+ def check_cancelled():
+ if download_dialog.cancelled:
+ self.speech_engine.cancel_download()
+ return not download_dialog.cancelled
+
+ # Start cancellation checker
+ cancel_check_id = GLib.timeout_add(100, check_cancelled)
+
+ try:
+ self._apply_settings_internal(settings)
+ GLib.idle_add(download_dialog.set_complete, True, "")
+ # Refresh model list after download to update icons
+ GLib.idle_add(self._populate_model_options)
+ finally:
+ GLib.source_remove(cancel_check_id)
+ self.speech_engine.set_download_progress_callback(None)
+
+ except Exception as e:
+ error_msg = str(e)
+ if "cancelled" in error_msg.lower():
+ GLib.idle_add(download_dialog.set_complete, False, "Download cancelled")
+ elif engine == "whisper" and "no module named" in error_msg.lower():
+ GLib.idle_add(
+ download_dialog.set_complete, False, "Whisper not installed"
+ )
+ GLib.idle_add(self._show_whisper_install_dialog)
+ else:
+ GLib.idle_add(download_dialog.set_complete, False, error_msg[:100])
+
+ threading.Thread(target=download_and_apply, daemon=True).start()
+ download_dialog.run()
+ download_dialog.destroy()
+ return
+
+ # Model already downloaded, just apply settings directly
+ logger.info(f"Auto-applying settings: {settings}")
+
# Update config manager
self.config_manager.update_speech_recognition_settings(settings)
self.config_manager.save_settings()
@@ -807,6 +952,8 @@ class SettingsDialog(Gtk.Dialog):
logger.info("Settings auto-applied successfully")
except Exception as e:
logger.error(f"Failed to auto-apply settings: {e}")
+ finally:
+ self._applying_settings = False
def _update_engine_specific_ui(self):
"""Show/hide UI elements specific to the selected engine."""
@@ -926,11 +1073,15 @@ class SettingsDialog(Gtk.Dialog):
engine_text = self.engine_combo.get_active_text()
# Get model by ID (which is the capitalized name) not the display text
model_id = self.model_combo.get_active_id()
+ # Get language by ID
+ language_id = self.language_combo.get_active_id()
# Handle cases where combo boxes might be empty (shouldn't happen with defaults)
engine = engine_text.lower() if engine_text else "vosk"
# Extract model name from ID (which is the capitalized name like "Small")
model_size = model_id.lower() if model_id else "small"
+ # Get language code (use "auto" as default)
+ language = language_id if language_id else "auto"
vad = int(self.vad_spin.get_value())
silence = self.silence_spin.get_value()
@@ -938,6 +1089,7 @@ class SettingsDialog(Gtk.Dialog):
settings = {
"engine": engine,
"model_size": model_size,
+ "language": language,
"vad_sensitivity": vad,
"silence_timeout": silence,
}
diff --git a/src/vocalinux/utils/vosk_model_info.py b/src/vocalinux/utils/vosk_model_info.py
index ac57fb9..9781a97 100644
--- a/src/vocalinux/utils/vosk_model_info.py
+++ b/src/vocalinux/utils/vosk_model_info.py
@@ -1,3 +1,60 @@
+# Language definitions with display names and Whisper/VOSK codes
+# Supported languages for speech recognition
+SUPPORTED_LANGUAGES = {
+ "auto": {
+ "name": "Auto-detect",
+ "whisper": None,
+ "vosk": None,
+ "warning": "Slower, may be less accurate",
+ },
+ "en-us": {
+ "name": "English (US)",
+ "whisper": "en",
+ "vosk": "vosk-model-small-en-us-0.15",
+ },
+ "hi": {
+ "name": "Hindi",
+ "whisper": "hi",
+ "vosk": "vosk-model-small-hi-0.22",
+ },
+ "es": {
+ "name": "Spanish",
+ "whisper": "es",
+ "vosk": "vosk-model-small-es-0.42",
+ },
+ "fr": {
+ "name": "French",
+ "whisper": "fr",
+ "vosk": "vosk-model-small-fr-0.22",
+ },
+ "de": {
+ "name": "German",
+ "whisper": "de",
+ "vosk": "vosk-model-small-de-0.15",
+ },
+ "it": {
+ "name": "Italian",
+ "whisper": "it",
+ "vosk": "vosk-model-small-it-0.22",
+ },
+ "pt": {
+ "name": "Portuguese",
+ "whisper": "pt",
+ "vosk": "vosk-model-small-pt-0.3",
+ },
+ "ru": {
+ "name": "Russian",
+ "whisper": "ru",
+ "vosk": "vosk-model-small-ru-0.22",
+ },
+ "zh": {
+ "name": "Chinese",
+ "whisper": "zh",
+ "vosk": "vosk-model-small-cn-0.22",
+ },
+}
+
+
# VOSK model metadata for display
# The large option uses the same model as medium, because 0.42 seems unavailable
# TODO @violog: specify correct size for each model
@@ -7,9 +64,15 @@ VOSK_MODEL_INFO = {
"desc": "Lightweight, fast",
"languages": {
"en-us": "vosk-model-small-en-us-0.15",
+ "en-in": "vosk-model-small-en-in-0.4",
+ "hi": "vosk-model-small-hi-0.22",
+ "es": "vosk-model-small-es-0.42",
"fr": "vosk-model-small-fr-0.22",
"de": "vosk-model-small-de-0.15",
+ "it": "vosk-model-small-it-0.22",
+ "pt": "vosk-model-small-pt-0.3",
"ru": "vosk-model-small-ru-0.22",
+ "zh": "vosk-model-small-cn-0.22",
},
},
"medium": {
@@ -20,6 +83,10 @@ VOSK_MODEL_INFO = {
"fr": "vosk-model-fr-0.22",
"de": "vosk-model-de-0.21",
"ru": "vosk-model-ru-0.22",
+ "hi": "vosk-model-hi-0.22",
+ "es": "vosk-model-es-0.42",
+ "pt": "vosk-model-pt-0.4",
+ "zh": "vosk-model-cn-0.22",
},
},
"large": {
@@ -30,6 +97,10 @@ VOSK_MODEL_INFO = {
"fr": "vosk-model-fr-0.22",
"de": "vosk-model-de-0.21",
"ru": "vosk-model-ru-0.22",
+ "hi": "vosk-model-hi-0.22",
+ "es": "vosk-model-es-0.42",
+ "pt": "vosk-model-pt-0.4",
+ "zh": "vosk-model-cn-0.22",
},
},
}