Files
voculinux/src/vocalinux/ui/logging_manager.py
T
ad84fd882a feat: add language selector UI with auto-detection support (#87)
* feat: add language selector UI with auto-detection support

This commit implements a comprehensive language selector for the GUI,
extending support from 4 to 10 languages and adding Whisper auto-detect.

Changes:
- Add SUPPORTED_LANGUAGES constant with 10 languages (auto, en-us, hi, es, fr, de, it, pt, ru, zh)
- Each language includes display name, Whisper code, VOSK model name, and optional warning
- Extend VOSK_MODEL_INFO to include Hindi, Spanish, Italian, Portuguese, and Chinese models
- Add language dropdown in Settings dialog with status indicators (✓ downloaded, ↓ needs download, ⚠ warning)
- Add language warning label for auto-detect and engine compatibility messages
- Update default language from "en-us" to "auto" for Whisper auto-detection
- Handle "auto" language in Whisper (passes None for auto-detect)
- Handle "auto" language in VOSK (falls back to "en-us" since VOSK doesn't support auto-detect)
- Update CLI --language argument to include all supported languages

Fixes the issue where Hindi speech was being transcribed as English
because the language was hardcoded to "en-us" which Whisper interpreted
as "force English transcription" rather than auto-detect.

Related PRs:
- #72 - Feature: Add language auto-detection and GUI selector (supersedes)
- #71 - Feature: Add multi-language support for FR, DE, RU (builds upon)
- #52 - Feature: 3 more languages for Whisper and VOSK (extends)
- #68 - Comment out language parameter in transcribe call (addresses)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* style: format main.py with black

* fix: use venv python and set GI_TYPELIB_PATH for verification

* fix: create wrapper scripts that set GI_TYPELIB_PATH

Problem: The symlinks in ~/.local/bin pointed to venv scripts that
failed because PyGObject couldn't find the system typelib files
(AppIndicator3 namespace not available).

Solution: Create wrapper scripts in ~/.local/bin that:
1. Set GI_TYPELIB_PATH=/usr/lib/girepository-1.0
2. Execute the venv's vocalinux scripts

This fixes the issue where vocalinux-gui would fail to start with:
ValueError: Namespace AppIndicator3 not available

* fix: set GI_TYPELIB_PATH in desktop entry Exec line

When launching from the app drawer, the environment variables from the
wrapper script weren't being inherited properly. By adding GI_TYPELIB_PATH
directly in the desktop entry's Exec line, the app can now find the
AppIndicator3 typelib files.

* fix: always create wrapper scripts, not just for remote installs

The wrapper scripts were only created when CLEANUP_ON_EXIT=yes, which
is only set for remote (curl-based) installations. When running from
a local repository, the wrapper scripts were never created, causing
the desktop entry and launch commands to fail.

* fix: prevent recursion in LoggingHandler when processing log records

The custom LoggingHandler was triggering logging from within the logging
system itself, causing infinite recursion when formatting log records.
This could happen when the logging formatter or any operation during
record processing triggered additional log messages.

Fix: Add _emitting flag to prevent recursive calls to emit().

* fix: prevent infinite loop in settings dialog when changing language

Add guard flags to prevent recursive event handler calls:
- _populating_models: blocks _on_model_changed during model population
- _processing_language_change: blocks recursive language change handling
- _applying_settings: blocks recursive settings application

These guards prevent the infinite loop that occurred when selecting a
language whose VOSK model wasn't downloaded yet.

Fixes issue where "Model small needs download" logged repeatedly.

* fix: handle language parameter in reconfigure and improve UX

- Fix reconfigure() to properly handle language parameter changes
- Remove _processing_language_change check from _auto_apply_settings
  to allow settings to be applied when language changes
- Hide unsupported languages in dropdown based on selected engine
  * VOSK: only show languages with available models (no "auto")
  * Whisper: show all supported languages including "auto"
- Remove duplicate _auto_apply_settings call in _on_engine_changed
- Set active language in combo after repopulating options

This fixes:
1. Whisper continuing to use old language after switching (e.g.,
   still transcribing in Hindi after switching to en-us)
2. Potential infinite loop when selecting languages for VOSK
3. User confusion by showing incompatible language options

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 23:16:06 -08:00

308 lines
9.1 KiB
Python

"""
Logging manager for VocaLinux.
This module provides centralized logging functionality with UI integration,
allowing users to view, filter, and export application logs for debugging.
"""
import logging
import os
import threading
import time
from datetime import datetime
from pathlib import Path
from typing import Callable, List, Optional
logger = logging.getLogger(__name__)
class LogRecord:
"""Represents a single log record with additional metadata."""
def __init__(
self, timestamp: datetime, level: str, logger_name: str, message: str, module: str = ""
):
self.timestamp = timestamp
self.level = level
self.logger_name = logger_name
self.message = message
self.module = module
def to_dict(self):
"""Convert to dictionary for easy serialization."""
return {
"timestamp": self.timestamp.isoformat(),
"level": self.level,
"logger_name": self.logger_name,
"message": self.message,
"module": self.module,
}
def __str__(self):
"""String representation for display."""
time_str = self.timestamp.strftime("%H:%M:%S.%f")[:-3] # Include milliseconds
return f"[{time_str}] {self.level:8} {self.logger_name:20} | {self.message}"
class LoggingManager:
"""
Centralized logging manager for VocaLinux.
This class captures all application logs, stores them in memory,
and provides functionality for viewing, filtering, and exporting logs.
"""
def __init__(self, max_records: int = 1000):
"""
Initialize the logging manager.
Args:
max_records: Maximum number of log records to keep in memory
"""
self.max_records = max_records
self.log_records: List[LogRecord] = []
self.log_callbacks: List[Callable[[LogRecord], None]] = []
self.lock = threading.Lock()
# Create logs directory
self.logs_dir = Path.home() / ".local" / "share" / "vocalinux" / "logs"
self.logs_dir.mkdir(parents=True, exist_ok=True)
# Set up custom log handler
self.handler = LoggingHandler(self)
self.handler.setLevel(logging.DEBUG)
# Create formatter
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
self.handler.setFormatter(formatter)
# Add handler to root logger
root_logger = logging.getLogger()
root_logger.addHandler(self.handler)
logger.info("Logging manager initialized")
def add_log_record(self, record: LogRecord):
"""
Add a new log record.
Args:
record: The log record to add
"""
with self.lock:
self.log_records.append(record)
# Trim old records if we exceed max_records
if len(self.log_records) > self.max_records:
self.log_records = self.log_records[-self.max_records :]
# Notify callbacks
for callback in self.log_callbacks:
try:
callback(record)
except Exception as e:
# Don't let callback errors break logging
print(f"Error in log callback: {e}")
def register_callback(self, callback: Callable[[LogRecord], None]):
"""
Register a callback to be called when new log records are added.
Args:
callback: Function to call with new log records
"""
with self.lock:
self.log_callbacks.append(callback)
def unregister_callback(self, callback: Callable[[LogRecord], None]):
"""
Unregister a log callback.
Args:
callback: The callback to remove
"""
with self.lock:
try:
self.log_callbacks.remove(callback)
except ValueError:
pass
def get_logs(
self,
level_filter: Optional[str] = None,
module_filter: Optional[str] = None,
last_n: Optional[int] = None,
) -> List[LogRecord]:
"""
Get log records with optional filtering.
Args:
level_filter: Filter by log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
module_filter: Filter by module name (partial match)
last_n: Return only the last N records
Returns:
List of filtered log records
"""
with self.lock:
records = self.log_records.copy()
# Apply filters
if level_filter:
records = [r for r in records if r.level == level_filter]
if module_filter:
records = [r for r in records if module_filter.lower() in r.logger_name.lower()]
# Return last N records
if last_n:
records = records[-last_n:]
return records
def export_logs(
self, filepath: str, level_filter: Optional[str] = None, module_filter: Optional[str] = None
) -> bool:
"""
Export logs to a file.
Args:
filepath: Path to save the log file
level_filter: Filter by log level
module_filter: Filter by module name
Returns:
True if export was successful, False otherwise
"""
try:
records = self.get_logs(level_filter, module_filter)
with open(filepath, "w", encoding="utf-8") as f:
f.write(f"VocaLinux Log Export\n")
f.write(f"Generated: {datetime.now().isoformat()}\n")
f.write(f"Total Records: {len(records)}\n")
if level_filter:
f.write(f"Level Filter: {level_filter}\n")
if module_filter:
f.write(f"Module Filter: {module_filter}\n")
f.write("=" * 80 + "\n\n")
for record in records:
f.write(str(record) + "\n")
logger.info(f"Exported {len(records)} log records to {filepath}")
return True
except Exception as e:
logger.error(f"Failed to export logs: {e}")
return False
def clear_logs(self):
"""Clear all stored log records."""
with self.lock:
self.log_records.clear()
logger.info("Log records cleared")
def get_log_stats(self) -> dict:
"""
Get statistics about the current logs.
Returns:
Dictionary with log statistics
"""
with self.lock:
records = self.log_records.copy()
if not records:
return {"total": 0, "by_level": {}, "by_module": {}, "oldest": None, "newest": None}
# Count by level
by_level = {}
for record in records:
by_level[record.level] = by_level.get(record.level, 0) + 1
# Count by module
by_module = {}
for record in records:
module = (
record.logger_name.split(".")[0]
if "." in record.logger_name
else record.logger_name
)
by_module[module] = by_module.get(module, 0) + 1
return {
"total": len(records),
"by_level": by_level,
"by_module": by_module,
"oldest": records[0].timestamp if records else None,
"newest": records[-1].timestamp if records else None,
}
class LoggingHandler(logging.Handler):
"""Custom logging handler that feeds records to the LoggingManager."""
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):
"""
Emit a log record to the logging manager.
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),
level=record.levelname,
logger_name=record.name,
message=record.getMessage(),
module=getattr(record, "module", record.name.split(".")[0]),
)
# Add to logging manager
self.logging_manager.add_log_record(log_record)
except Exception:
# Don't let logging errors break the application
self.handleError(record)
finally:
self._emitting = False
# Global logging manager instance
_logging_manager: Optional[LoggingManager] = None
def get_logging_manager() -> LoggingManager:
"""
Get the global logging manager instance.
Returns:
The global LoggingManager instance
"""
global _logging_manager
if _logging_manager is None:
_logging_manager = LoggingManager()
return _logging_manager
def initialize_logging():
"""Initialize the global logging manager."""
global _logging_manager
if _logging_manager is None:
_logging_manager = LoggingManager()
logger.info("Global logging manager initialized")