feat: Alpha Launch v0.2.0 - Complete UX Overhaul 🚀🎤 (#39)

* feat: prepare for v0.2.0-alpha release

* feat: prepare for v0.2.0-alpha release

* chore: remove CHANGELOG.md, use GitHub Releases instead

* refactor: use existing About dialog instead of Settings section

* style: enhance About dialog UI readability

* style: fix formatting with black and isort

* feat: show VAD and silence timeout settings for Whisper engine

- Renamed 'VOSK Settings' to 'Recognition Settings'
- VAD Sensitivity and Silence Timeout now apply to both engines
- Added tooltips explaining each setting
- VOSK-specific model info only shown when VOSK selected

* fix: resolve overlapping UI elements in settings dialog

* fix: save VAD and silence timeout settings for both engines

Previously these settings were only saved when VOSK was selected.
Now they persist correctly for Whisper as well.

* fix: show download progress when selecting undownloaded model

Previously when auto-applying settings, models would download silently
in the background without showing the progress dialog. Now:
- Download progress dialog appears for undownloaded models
- Model icons update correctly after download completes

* feat: add download progress, speed, ETA and cancel button

- Show real download progress percentage for VOSK models
- Display download speed (MB/s) and estimated time remaining
- Add Cancel button to abort downloads
- Update status during extraction phase
- Whisper downloads still pulse (uses internal downloader)

* fix: defer model download until needed, don't block startup

- App now starts instantly even if models aren't downloaded
- Models are downloaded on-demand when selected in Settings
- If user tries to dictate without a model, plays error sound
- Added model_ready property to check if engine is usable

* fix: improve first-run UX without downloaded models

- Settings dialog now defaults to smallest available model, not saved config
- Only show saved model if it's actually downloaded
- Add desktop notification when trying to dictate without a model
- Prevents auto-downloading large models on first launch

* feat: add download progress tracking for Whisper models

- Implement custom Whisper model download with progress/speed/ETA
- Uses OpenAI's CDN URLs directly instead of whisper.load_model()
- Progress callback works the same as VOSK downloads
- Supports cancellation during download

* fix: enable download progress callback for Whisper models

Progress callback was only being set for VOSK downloads, not Whisper.
Now both engines show real-time progress, speed, and ETA.

* fix: update tests for defer_download and config manager mocking

- test_main: Mock ConfigManager to prevent saved config from overriding test values
- test_settings_dialog: Mock ModelDownloadDialog to prevent GTK init errors
- test_speech_recognition: Use proper MockVoskModule class instead of MagicMock, pass defer_download=False for download test
- test_whisper_support: Pass defer_download=False to trigger model loading in test

* fix: resolve test failures for CI

- test_main: Fix ConfigManager patch path to use source module
- test_settings_dialog: Mock _is_vosk_model_downloaded and _is_whisper_model_downloaded to skip download flow, add missing dialog attributes
- test_speech_recognition: Replace recognizer with properly configured mock that returns valid JSON, fixing test isolation issues when running all tests together

* fix: mock _download_whisper_model in whisper test to avoid requests exception handling issue

* fix: add gir1.2-girepository-2.0 dep for PyGObject on Ubuntu 24.04

- Add missing girepository-2.0 package for newer Ubuntu runners
- Use --no-build-isolation for PyGObject to use system packages

* fix: skip PyGObject pip install, use system python3-gi package

- Use --no-deps for editable install to skip PyGObject
- Install other deps explicitly without PyGObject
- Add python3-gi-cairo for complete GTK support
- Removes problematic girepository-2.0 build requirement

* fix: install PyGObject via pip with proper build deps

- Remove python3-gi/python3-gi-cairo (doesn't work with setup-python)
- Install PyGObject from pip after installing build dependencies
- Simplify venv setup without --system-site-packages hack

* Fix GObject

* fix: set GI_TYPELIB_PATH for pip-installed PyGObject to find AppIndicator3
This commit is contained in:
Jatin K Malik
2025-12-28 15:29:51 -08:00
committed by GitHub
parent 917510d3f4
commit 1dc1d44665
42 changed files with 2222 additions and 1299 deletions
+42
View File
@@ -0,0 +1,42 @@
---
name: Bug Report
about: Report a bug to help us improve Vocalinux
title: "[BUG] "
labels: bug
assignees: ''
---
## 🐛 Bug Description
A clear and concise description of what the bug is.
## 📋 Steps to Reproduce
1. Go to '...'
2. Click on '...'
3. Speak '...'
4. See error
## ✅ Expected Behavior
A clear and concise description of what you expected to happen.
## ❌ Actual Behavior
What actually happened.
## 📸 Screenshots
If applicable, add screenshots to help explain your problem.
## 💻 Environment
- **OS**: [e.g., Ubuntu 22.04]
- **Python Version**: [e.g., 3.10]
- **Vocalinux Version**: [e.g., 0.2.0-alpha]
- **Display Server**: [X11 / Wayland]
- **Speech Engine**: [VOSK / Whisper]
- **Model Size**: [small / medium / large]
## 📋 Debug Logs
Run `vocalinux --debug` and paste relevant log output:
```
[paste logs here]
```
## 📝 Additional Context
Add any other context about the problem here.
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: 💬 Questions & Discussions
url: https://github.com/jatinkrmalik/vocalinux/discussions
about: Ask questions or discuss ideas in GitHub Discussions
- name: 📖 Documentation
url: https://github.com/jatinkrmalik/vocalinux/tree/main/docs
about: Check out the documentation before opening an issue
+31
View File
@@ -0,0 +1,31 @@
---
name: Feature Request
about: Suggest an idea for Vocalinux
title: "[FEATURE] "
labels: enhancement
assignees: ''
---
## 🚀 Feature Description
A clear and concise description of the feature you'd like.
## 💡 Problem Statement
Is your feature request related to a problem? Please describe.
Example: "I'm always frustrated when [...]"
## 📋 Proposed Solution
Describe the solution you'd like. Be as detailed as possible.
## 🔄 Alternatives Considered
Describe any alternative solutions or features you've considered.
## 📸 Mockups / Examples
If applicable, add mockups, screenshots, or examples from other applications.
## 📝 Additional Context
Add any other context about the feature request here.
## ✅ Acceptance Criteria
- [ ] Criterion 1
- [ ] Criterion 2
- [ ] Criterion 3
+33
View File
@@ -0,0 +1,33 @@
## Description
<!-- Describe your changes in detail -->
## Related Issue
<!-- Link to the related issue if applicable -->
Fixes #
## Type of Change
- [ ] 🐛 Bug fix (non-breaking change which fixes an issue)
- [ ] ✨ New feature (non-breaking change which adds functionality)
- [ ] 💥 Breaking change (fix or feature that would cause existing functionality to change)
- [ ] 📖 Documentation update
- [ ] 🧹 Code refactoring
- [ ] ✅ Test update
## Checklist
- [ ] My code follows the code style of this project (black, isort)
- [ ] I have updated the documentation accordingly
- [ ] I have added tests to cover my changes
- [ ] All new and existing tests pass locally
- [ ] Pre-commit hooks pass
## Screenshots (if applicable)
<!-- Add screenshots to help explain your changes -->
## Additional Notes
<!-- Add any additional notes for reviewers -->
+78 -6
View File
@@ -5,33 +5,105 @@ on:
tags:
- 'v*'
permissions:
contents: write
packages: write
env:
PYTHON_VERSION: '3.10'
jobs:
build-and-release:
name: Build and Create Release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: '3.10'
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
- name: Install dependencies
- name: Install build dependencies
run: |
python -m pip install --upgrade pip
pip install build twine wheel
pip install build twine wheel setuptools
- name: Extract version from tag
id: get_version
run: |
VERSION=${GITHUB_REF#refs/tags/v}
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
echo "Building version: $VERSION"
- name: Verify version consistency
run: |
# Get version from Python package
PACKAGE_VERSION=$(python -c "from src.vocalinux.version import __version__; print(__version__)")
TAG_VERSION=${{ steps.get_version.outputs.VERSION }}
echo "Package version: $PACKAGE_VERSION"
echo "Tag version: $TAG_VERSION"
# Allow alpha/beta suffixes in package version
if [[ ! "$PACKAGE_VERSION" == *"$TAG_VERSION"* ]] && [[ ! "$TAG_VERSION" == *"$PACKAGE_VERSION"* ]]; then
echo "Warning: Version mismatch between tag ($TAG_VERSION) and package ($PACKAGE_VERSION)"
fi
- name: Build package
run: |
python -m build
echo "Built packages:"
ls -la dist/
- name: Create Release
id: create_release
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@v2
with:
files: |
dist/*.whl
dist/*.tar.gz
generate_release_notes: true
draft: false
prerelease: ${{ contains(github.ref, 'alpha') || contains(github.ref, 'beta') || contains(github.ref, 'rc') }}
body: |
## Installation
### Quick Install (Recommended)
```bash
curl -fsSL https://raw.githubusercontent.com/jatinkrmalik/vocalinux/main/install.sh | bash
```
### From Source
```bash
git clone https://github.com/jatinkrmalik/vocalinux.git
cd vocalinux
./install.sh
```
### From PyPI (if available)
```bash
pip install vocalinux
```
---
See [CHANGELOG](https://github.com/jatinkrmalik/vocalinux/releases) for detailed changes.
# Optional: publish to PyPI when ready
# publish-pypi:
# name: Publish to PyPI
# needs: build-and-release
# runs-on: ubuntu-latest
# if: ${{ !contains(github.ref, 'alpha') && !contains(github.ref, 'beta') }}
# steps:
# - uses: actions/checkout@v4
# - uses: actions/setup-python@v5
# with:
# python-version: ${{ env.PYTHON_VERSION }}
# - run: pip install build twine
# - run: python -m build
# - uses: pypa/gh-action-pypi-publish@release/v1
# with:
# password: ${{ secrets.PYPI_API_TOKEN }}
+55 -173
View File
@@ -1,23 +1,12 @@
name: Vocalinux Unified Pipeline
name: Vocalinux CI
on:
push:
branches: [ main, master ]
paths-ignore:
- '**.md'
- 'docs/**'
pull_request:
branches: [ main, master ]
paths-ignore:
- '**.md'
- 'docs/**'
workflow_dispatch:
# Define path filters for different components
env:
WEB_FILES_FILTER: 'web/**'
PYTHON_FILES_FILTER: 'src/**,tests/**,setup.py'
permissions:
contents: write
pages: write
@@ -25,106 +14,61 @@ permissions:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
cancel-in-progress: true
jobs:
python-lint:
name: Python Lint
name: 🔍 Lint Python
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# This step checks if Python files were changed
- name: Check for Python file changes
uses: dorny/paths-filter@v2
id: changes
with:
filters: |
python:
- '${{ env.PYTHON_FILES_FILTER }}'
# Skip the rest of the job if no Python files were changed
- name: Skip if no Python files changed
if: steps.changes.outputs.python != 'true' && github.event_name != 'workflow_dispatch' && !contains(github.event.head_commit.message, '[python]')
run: |
echo "No Python files were changed. Skipping Python linting."
exit 0
- name: Set up Python
if: steps.changes.outputs.python == 'true' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[python]')
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: '3.10'
cache: 'pip'
cache-dependency-path: 'setup.py'
- name: Install development dependencies
if: steps.changes.outputs.python == 'true' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[python]')
- name: Install linting tools
run: |
python -m pip install --upgrade pip
python -m pip install flake8 black isort
pip install flake8 black isort
- name: Lint with flake8
if: steps.changes.outputs.python == 'true' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[python]')
- name: Lint with flake8 (critical errors)
run: |
# stop the build if there are Python syntax errors or undefined names
flake8 src/ tests/ --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings
flake8 src/ tests/ --count --exit-zero --max-complexity=10 --max-line-length=100 --statistics
- name: Check formatting with black
if: steps.changes.outputs.python == 'true' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[python]')
run: |
black --check src/ tests/
black --check --diff src/ tests/
- name: Check import sorting with isort
if: steps.changes.outputs.python == 'true' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[python]')
run: |
isort --check-only --profile black src/ tests/
isort --check-only --diff --profile black src/ tests/
python-test:
name: Python Test
name: 🧪 Test Python ${{ matrix.python-version }}
runs-on: ubuntu-latest
needs: python-lint
strategy:
fail-fast: false
matrix:
python-version: [3.8]
python-version: ['3.8', '3.10', '3.11']
steps:
- uses: actions/checkout@v4
# This step checks if Python files were changed
- name: Check for Python file changes
uses: dorny/paths-filter@v2
id: changes
with:
filters: |
python:
- '${{ env.PYTHON_FILES_FILTER }}'
# Skip the rest of the job if no Python files were changed
- name: Skip if no Python files changed
if: steps.changes.outputs.python != 'true' && github.event_name != 'workflow_dispatch' && !contains(github.event.head_commit.message, '[python]')
run: |
echo "No Python files were changed. Skipping Python tests."
exit 0
- name: Set up Python ${{ matrix.python-version }}
if: steps.changes.outputs.python == 'true' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[python]')
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
cache-dependency-path: 'setup.py'
- name: Install system dependencies
if: steps.changes.outputs.python == 'true' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[python]')
run: |
sudo apt-get update
sudo apt-get install -y \
xdotool \
python3-gi \
gir1.2-appindicator3-0.1 \
libcairo2-dev \
pkg-config \
@@ -135,74 +79,55 @@ jobs:
gir1.2-gtk-3.0 \
portaudio19-dev
- name: Cache apt packages
if: steps.changes.outputs.python == 'true' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[python]')
uses: actions/cache@v3
with:
path: /var/cache/apt/archives
key: ${{ runner.os }}-apt-${{ hashFiles('**/apt-packages.txt') }}
restore-keys: |
${{ runner.os }}-apt-
- name: Install GObject introspection dependencies
run: |
sudo apt-get update
# For Ubuntu 24.04+ (or the latest GitHub Actions runner image)
sudo apt-get install -y gir1.2-glib-2.0 libgirepository-2.0-dev pkg-config
# Alternatively, for older Ubuntu versions (like 22.04), you might need:
# sudo apt-get install -y libgirepository1.0-dev pkg-config
- name: Set up virtual environment
if: steps.changes.outputs.python == 'true' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[python]')
run: |
python -m venv venv
source venv/bin/activate
python -m pip install --upgrade pip
# Install the package in development mode with dev dependencies
python -m pip install -e ".[dev]"
python -m pip install pytest pytest-cov
python -m pip install --upgrade pip setuptools wheel
# Install PyGObject from pip (with build deps installed above)
pip install PyGObject
pip install -e ".[dev]"
echo "VIRTUAL_ENV=$VIRTUAL_ENV" >> $GITHUB_ENV
echo "PATH=$VIRTUAL_ENV/bin:$PATH" >> $GITHUB_ENV
- name: Run pytest suite with coverage
if: steps.changes.outputs.python == 'true' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[python]')
- name: Run tests with coverage
run: |
source venv/bin/activate
# Make sure Python can find the 'src' module
PYTHONPATH=$PWD pytest --junitxml=junit.xml --cov=src tests/
# Set GI_TYPELIB_PATH so pip-installed PyGObject can find system typelibs (AppIndicator3, etc.)
export GI_TYPELIB_PATH=/usr/lib/x86_64-linux-gnu/girepository-1.0:/usr/lib/girepository-1.0
PYTHONPATH=$PWD pytest --junitxml=junit-${{ matrix.python-version }}.xml --cov=src --cov-report=xml tests/
- name: Upload test results to Codecov
if: ${{ !cancelled() && (steps.changes.outputs.python == 'true' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[python]')) }}
uses: codecov/test-results-action@v1
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-${{ matrix.python-version }}
path: junit-${{ matrix.python-version }}.xml
- name: Upload coverage to Codecov
if: matrix.python-version == '3.10'
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: coverage.xml
fail_ci_if_error: false
- name: Upload coverage reports to Codecov
if: ${{ steps.changes.outputs.python == 'true' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[python]') }}
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
web-validate:
name: Web Validation
web-build:
name: 🌐 Build Website
runs-on: ubuntu-latest
# Use GitHub's path filtering to determine if this job should run
if: ${{ github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[web]') }}
if: contains(github.event.head_commit.message, '[web]') || github.event_name == 'workflow_dispatch'
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: actions/checkout@v4
# This step checks if web files were changed
- name: Check for web file changes
uses: dorny/paths-filter@v2
id: changes
with:
filters: |
web:
- '${{ env.WEB_FILES_FILTER }}'
# Skip the rest of the job if no web files were changed
- name: Skip if no web files changed
if: steps.changes.outputs.web != 'true' && github.event_name != 'workflow_dispatch' && !contains(github.event.head_commit.message, '[web]')
run: |
echo "No web files were changed. Skipping web validation."
exit 0
# Continue with web validation if files were changed
- name: Setup Node.js
if: steps.changes.outputs.web == 'true' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[web]')
uses: actions/setup-node@v4
with:
node-version: '20'
@@ -210,90 +135,47 @@ jobs:
cache-dependency-path: './web/package-lock.json'
- name: Install dependencies
if: steps.changes.outputs.web == 'true' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[web]')
working-directory: ./web
run: |
if [ -f "package-lock.json" ]; then
npm ci
else
npm install
fi
run: npm ci || npm install
- name: Lint
if: steps.changes.outputs.web == 'true' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[web]')
working-directory: ./web
run: npm run lint || echo "Linting failed but continuing with the build"
run: npm run lint || true
- name: Type check
if: steps.changes.outputs.web == 'true' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[web]')
working-directory: ./web
run: npm run test:types || echo "Type checking failed but continuing with the build"
- name: Format check
if: steps.changes.outputs.web == 'true' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[web]')
working-directory: ./web
run: npm run format:check || echo "Format checking failed but continuing with the build"
run: npm run test:types || true
- name: Build
if: steps.changes.outputs.web == 'true' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[web]')
working-directory: ./web
run: npm run build
deploy-website:
name: Deploy Website
name: 🚀 Deploy Website
runs-on: ubuntu-latest
needs: [python-test, web-validate]
# Only deploy on pushes to main branch
if: ${{ github.event_name != 'pull_request' && github.ref == 'refs/heads/main' }}
needs: [python-test, web-build]
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && contains(github.event.head_commit.message, '[web]')
steps:
- name: Checkout
uses: actions/checkout@v4
# This step checks if web files were changed
- name: Check for web file changes
uses: dorny/paths-filter@v2
id: changes
with:
filters: |
web:
- '${{ env.WEB_FILES_FILTER }}'
# Skip the rest of the job if no web files were changed
- name: Skip if no web files changed
if: steps.changes.outputs.web != 'true' && !contains(github.event.head_commit.message, '[web]')
run: |
echo "No web files were changed. Skipping website deployment."
exit 0
- uses: actions/checkout@v4
- name: Setup Node.js
if: steps.changes.outputs.web == 'true' || contains(github.event.head_commit.message, '[web]')
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: './web/package-lock.json'
- name: Install dependencies
if: steps.changes.outputs.web == 'true' || contains(github.event.head_commit.message, '[web]')
- name: Install and build
working-directory: ./web
run: |
if [ -f "package-lock.json" ]; then
npm ci
else
npm install
fi
- name: Build website
if: steps.changes.outputs.web == 'true' || contains(github.event.head_commit.message, '[web]')
working-directory: ./web
run: npm run deploy
npm ci || npm install
npm run deploy
- name: Deploy to gh-pages
if: steps.changes.outputs.web == 'true' || contains(github.event.head_commit.message, '[web]')
uses: peaceiris/actions-gh-pages@v3
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./web/out
publish_branch: gh-pages
cname: vocalinux.com
force_orphan: true
force_orphan: true
+18 -8
View File
@@ -1,26 +1,36 @@
# Pre-commit hooks for Vocalinux
# Run: pre-commit run --all-files
repos:
# Code formatting with Black
- repo: https://github.com/psf/black
rev: 24.1.1
hooks:
- id: black
args: [--check]
language_version: python3
files: ^(src|tests)/
# Import sorting with isort (black-compatible)
- repo: https://github.com/pycqa/isort
rev: 5.13.2
hooks:
- id: isort
args: ["--check-only", "--profile", "black"]
args: ["--profile", "black"]
files: ^(src|tests)/
# Linting with flake8 (critical errors only)
- repo: https://github.com/pycqa/flake8
rev: 7.0.0
hooks:
- id: flake8
name: flake8 (critical)
args: ["--count", "--select=E9,F63,F7,F82", "--show-source", "--statistics"]
files: ^(src|tests)/
- id: flake8
name: flake8 (warnings)
args: ["--count", "--exit-zero", "--max-complexity=10", "--max-line-length=100", "--statistics"]
args: ["--select=E9,F63,F7,F82", "--max-line-length=100"]
files: ^(src|tests)/
# Basic file checks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
+201 -158
View File
@@ -1,239 +1,282 @@
# Contributing to Vocalinux
Thank you for your interest in contributing to Vocalinux! This document provides guidelines and instructions for contributing to the project.
Thank you for your interest in contributing to Vocalinux! 🎉
## Development Environment Setup
This document provides guidelines and instructions for contributing to the project.
## 📋 Table of Contents
- [Code of Conduct](#code-of-conduct)
- [Getting Started](#getting-started)
- [Development Setup](#development-setup)
- [Making Changes](#making-changes)
- [Testing](#testing)
- [Pull Request Process](#pull-request-process)
- [Release Process](#release-process)
- [Community](#community)
## Code of Conduct
We are committed to providing a welcoming and inclusive environment. Please be respectful and constructive in your interactions.
## Getting Started
### Ways to Contribute
- 🐛 **Report bugs** - Found a bug? [Open an issue](https://github.com/jatinkrmalik/vocalinux/issues/new)
- 💡 **Suggest features** - Have an idea? [Start a discussion](https://github.com/jatinkrmalik/vocalinux/discussions)
- 📖 **Improve documentation** - Docs can always be better!
- 🔧 **Fix bugs** - Check the [issues](https://github.com/jatinkrmalik/vocalinux/issues) for things to work on
- ✨ **Add features** - Pick up a feature from the roadmap
### Good First Issues
New to the project? Look for issues labeled [`good first issue`](https://github.com/jatinkrmalik/vocalinux/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22).
## Development Setup
### Option 1: Automated Setup (Recommended)
The easiest way to set up a development environment is using the installer script with the `--dev` flag:
```bash
# Clone the repository
# Fork and clone the repository
git clone https://github.com/YOUR-USERNAME/vocalinux.git
cd vocalinux
# Install in development mode
# Install in development mode (includes all dev dependencies)
./install.sh --dev
```
This will:
1. Install all system dependencies
2. Create a Python virtual environment
3. Install the package in development mode with the `-e` flag
4. Install all development dependencies including testing tools
5. Run the tests automatically
3. Install the package in editable mode (`-e`)
4. Install all dev dependencies (pytest, black, isort, flake8)
5. Run the test suite automatically
### Option 2: Manual Setup
If you prefer to set up your development environment manually:
1. Fork the repository on GitHub
2. Clone your fork locally:
1. **Fork and clone:**
```bash
git clone https://github.com/YOUR-USERNAME/vocalinux.git
cd vocalinux
```
3. Install system dependencies:
2. **Install system dependencies:**
```bash
# For Ubuntu/Debian
# Ubuntu/Debian
sudo apt update
sudo apt install -y python3-pip python3-gi python3-gi-cairo gir1.2-gtk-3.0 \
gir1.2-appindicator3-0.1 libgirepository1.0-dev python3-dev portaudio19-dev python3-venv
# For X11 environments
sudo apt install -y xdotool
# For Wayland environments
sudo apt install -y wtype
sudo apt install -y python3-pip python3-gi python3-gi-cairo \
gir1.2-gtk-3.0 gir1.2-appindicator3-0.1 libgirepository1.0-dev \
python3-dev portaudio19-dev python3-venv xdotool
```
4. Create and activate a virtual environment:
3. **Set up Python environment:**
```bash
python3 -m venv venv --system-site-packages
source venv/bin/activate
```
5. Install the package in development mode:
```bash
# Update pip and setuptools
pip install --upgrade pip setuptools wheel
# Install in development mode with all dev dependencies
pip install -e ".[dev]"
```
6. Install pre-commit hooks:
4. **(Optional) Install pre-commit hooks:**
```bash
pre-commit install
```
> **Note:** Pre-commit hooks are optional. The CI pipeline runs the same checks, so you can skip this if you prefer faster local commits.
## Directory Structure
## Making Changes
The project follows this directory structure:
### Branching Strategy
```bash
# Create a feature branch from main
git checkout main
git pull origin main
git checkout -b feature/your-feature-name
# Or for bug fixes
git checkout -b fix/issue-description
```
**Branch naming conventions:**
- `feature/` - New features
- `fix/` - Bug fixes
- `docs/` - Documentation updates
- `refactor/` - Code refactoring
- `test/` - Test additions/updates
### Code Style
We use automated tools to ensure consistent code style:
- **Black** - Code formatting (line length: 100)
- **isort** - Import sorting (black-compatible profile)
- **flake8** - Linting
```bash
# Format your code
black src/ tests/
isort src/ tests/
# Check for issues
flake8 src/ tests/
```
Pre-commit hooks will run these automatically before each commit.
### Project Structure
```
vocalinux/
├── docs/ # Documentation
│ ├── INSTALL.md # Installation guide
│ └── USER_GUIDE.md # User guide
├── resources/ # Resource files
│ ├── icons/ # Application icons
│ └── sounds/ # Audio notification sounds
├── src/ # Source code
│ ├── speech_recognition/ # Speech recognition components
│ ├── text_injection/ # Text injection components
│ └── ui/ # User interface components
├── tests/ # Test suite
├── CONTRIBUTING.md # Contribution guidelines (this file)
├── install.sh # Installation script
├── LICENSE # License information
├── README.md # Project overview
└── setup.py # Python package configuration
├── src/vocalinux/ # Main application code
│ ├── __init__.py
│ ├── main.py # Entry point
│ ├── version.py # Version information
│ ├── common_types.py # Shared types/enums
│ ├── speech_recognition/ # Speech recognition engines
│ │ ├── recognition_manager.py
│ │ └── command_processor.py
│ ├── text_injection/ # Text injection (X11/Wayland)
│ │ └── text_injector.py
│ ├── ui/ # GTK UI components
│ │ ├── tray_indicator.py
│ │ ├── settings_dialog.py
│ │ ├── config_manager.py
│ │ └── ...
│ └── utils/ # Utility functions
├── tests/ # Test suite
├── resources/ # Icons and sounds
├── docs/ # Documentation
└── web/ # Website source (Next.js)
```
## Pre-commit Hooks
### Key Files for Common Tasks
We use pre-commit hooks to ensure code quality and consistency. These hooks automatically run before each commit to check your code against our standards.
### Pre-commit Configuration
The pre-commit configuration in `.pre-commit-config.yaml` includes:
1. **black**: Code formatter (line length: 100)
2. **isort**: Import sorter (configured to be compatible with black)
3. **flake8**: Linter with plugins for docstrings, comprehensions, and bug detection
4. **Additional checks**: Trailing whitespace, file endings, YAML/JSON validation, etc.
### Running Pre-commit Manually
You can run the pre-commit checks manually on all files:
```bash
pre-commit run --all-files
```
Or on specific files:
```bash
pre-commit run --files path/to/file1.py path/to/file2.py
```
### Bypassing Pre-commit (Emergency Only)
In emergency situations only, you can bypass pre-commit hooks:
```bash
git commit -m "Your message" --no-verify
```
However, this is strongly discouraged as it may lead to CI pipeline failures.
| Task | Files |
|------|-------|
| Add voice command | `src/vocalinux/speech_recognition/command_processor.py` |
| UI changes | `src/vocalinux/ui/*.py` |
| Speech recognition | `src/vocalinux/speech_recognition/recognition_manager.py` |
| Text injection | `src/vocalinux/text_injection/text_injector.py` |
| Settings | `src/vocalinux/ui/config_manager.py`, `settings_dialog.py` |
## Testing
Write tests for all new features and bug fixes:
### Running Tests
```bash
# Activate virtual environment
source venv/bin/activate
# Run all tests
pytest
# Run a specific test file
pytest tests/test_specific_file.py
# Run tests with coverage report
pytest --cov=src
# Generate HTML coverage report
# Run with coverage
pytest --cov=src --cov-report=html
# Run specific test file
pytest tests/test_command_processor.py
# Run with verbose output
pytest -v
```
Aim for at least 80% test coverage for new code.
### Writing Tests
## Code Style Guidelines
- Place tests in the `tests/` directory
- Name test files as `test_*.py`
- Name test functions as `test_*`
- Aim for at least 80% coverage for new code
- Use `pytest-mock` for mocking
We follow these code style conventions:
1. **PEP 8**: With modifications:
- Maximum line length: 100 characters
- Use Black formatting
2. **Docstrings**: All public methods, classes, and modules should have docstrings
3. **Imports**: Organized using isort with the following sections:
- Standard library imports
- Third-party imports
- Local application imports
4. **Type Annotations**: Use type hints where appropriate
Example test:
```python
def test_command_processor_new_line(mocker):
"""Test that 'new line' command returns correct action."""
processor = CommandProcessor()
result = processor.process("new line")
assert result.action == "new_line"
```
## Pull Request Process
1. Ensure all pre-commit checks pass
2. Update documentation if necessary
3. Add or update tests as needed
4. Create a pull request with a clear description of the changes
5. Link any related issues
6. Wait for code review
### Before Submitting
## Commit Message Guidelines
- [ ] Code follows the style guidelines
- [ ] Tests pass locally (`pytest`)
- [ ] Pre-commit hooks pass
- [ ] Documentation is updated (if needed)
- [ ] Commit messages are clear and descriptive
Follow these guidelines for commit messages:
### Commit Messages
- Use the present tense ("Add feature" not "Added feature")
- Use the imperative mood ("Move cursor to..." not "Moves cursor to...")
- Limit the first line to 72 characters or less
- Reference issues and pull requests after the first line
Follow the [Conventional Commits](https://www.conventionalcommits.org/) style:
Example:
```
Add voice command for deleting sentences
type(scope): short description
This implements the "delete that" command functionality
to remove the last sentence entered by the user.
Longer description if needed.
Fixes #123
```
## Documentation
**Types:** `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
Update documentation for any new features or changes:
**Examples:**
```
feat(commands): add "select all" voice command
fix(tray): resolve icon not updating on Wayland
docs(readme): update installation instructions
```
- Add docstrings to new functions, classes, and methods
- Update README.md or relevant docs in the docs/ directory
- Add or update code examples if applicable
### Submitting
## Development Workflow Tips
1. Push your branch to your fork
2. Open a Pull Request against `main`
3. Fill out the PR template
4. Link any related issues
5. Wait for CI to pass
6. Request a review
1. **Virtual Environment**: Always activate the virtual environment before working on the project:
```bash
# Activate the environment (assuming it's named venv)
source venv/bin/activate
# Or use the helper script
# source activate-vocalinux.sh
```
### Review Process
2. **Running from Source**: To ensure you are running the latest code from your `src` directory during development (instead of the potentially installed version), use:
```bash
# Make sure venv is active
python -m vocalinux.main
```
This command executes the main module directly from your source files.
- PRs require at least one approval
- CI must pass (linting, tests)
- Maintainers may request changes
- Once approved, maintainers will merge
3. **Debugging**: Use the `--debug` flag when running Vocalinux during development:
```bash
# When running from source
python -m vocalinux.main --debug
# If running the installed command (less common during active dev)
# vocalinux --debug
```
## Release Process
4. **Branching Strategy**:
- Create feature branches from `main`
- Name branches descriptively (e.g., `feature/voice-commands`, `fix/tray-icon-bug`)
- Keep pull requests focused on a single issue/feature
Releases are managed through GitHub tags and the release workflow.
5. **Common Development Tasks**:
- Adding a new voice command? Modify `command_processor.py`
- UI changes? Look at files in the `ui/` directory
- Speech recognition tweaks? Check `speech_recognition/recognition_manager.py`
### Version Bumping
Thank you for contributing to Vocalinux!
1. Update version in `src/vocalinux/version.py`
2. Commit: `git commit -m "chore: bump version to x.y.z"`
3. Tag: `git tag vx.y.z`
4. Push: `git push origin main --tags`
5. GitHub Release will be auto-created with release notes
### Versioning Scheme
We follow [Semantic Versioning](https://semver.org/):
- **MAJOR.MINOR.PATCH** (e.g., `1.2.3`)
- Pre-release: `x.y.z-alpha`, `x.y.z-beta`, `x.y.z-rc.1`
## Community
### Getting Help
- 💬 [GitHub Discussions](https://github.com/jatinkrmalik/vocalinux/discussions) - Ask questions
- 🐛 [GitHub Issues](https://github.com/jatinkrmalik/vocalinux/issues) - Report bugs
### Stay Connected
- ⭐ Star the repository to show support
- 👀 Watch for updates
- 🐦 Follow [@jatinkrmalik](https://twitter.com/jatinkrmalik) on Twitter
---
Thank you for contributing to Vocalinux! ❤️
+105
View File
@@ -0,0 +1,105 @@
# Vocalinux Makefile
# Convenient commands for development
.PHONY: help install install-dev test lint format clean build release
# Default target
help:
@echo "Vocalinux Development Commands"
@echo "=============================="
@echo ""
@echo "Setup:"
@echo " make install - Install Vocalinux"
@echo " make install-dev - Install in development mode"
@echo ""
@echo "Development:"
@echo " make test - Run test suite"
@echo " make lint - Run linters (flake8, black, isort)"
@echo " make format - Auto-format code"
@echo " make typecheck - Run type checking (mypy)"
@echo ""
@echo "Build:"
@echo " make build - Build distribution packages"
@echo " make clean - Remove build artifacts"
@echo ""
@echo "Run:"
@echo " make run - Run the application"
@echo " make run-debug - Run with debug logging"
# Installation
install:
./install.sh
install-dev:
./install.sh --dev
# Testing
test:
@echo "Running tests..."
pytest -v
test-cov:
@echo "Running tests with coverage..."
pytest --cov=src --cov-report=html --cov-report=term
@echo "Coverage report generated in htmlcov/"
# Linting
lint:
@echo "Running flake8..."
flake8 src/ tests/ --count --select=E9,F63,F7,F82 --show-source --statistics
@echo "Checking black formatting..."
black --check --diff src/ tests/
@echo "Checking isort..."
isort --check-only --diff --profile black src/ tests/
format:
@echo "Formatting with black..."
black src/ tests/
@echo "Sorting imports with isort..."
isort --profile black src/ tests/
typecheck:
@echo "Running mypy..."
mypy src/
# Build
build:
@echo "Building distribution packages..."
python -m build
@echo "Built packages in dist/"
clean:
@echo "Cleaning build artifacts..."
rm -rf build/
rm -rf dist/
rm -rf *.egg-info
rm -rf src/*.egg-info
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
find . -type f -name "*.pyc" -delete
find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true
find . -type d -name ".mypy_cache" -exec rm -rf {} + 2>/dev/null || true
rm -rf htmlcov/
rm -f .coverage
rm -f coverage.xml
@echo "Clean complete"
# Run
run:
vocalinux
run-debug:
vocalinux --debug
run-source:
python -m vocalinux.main
run-source-debug:
python -m vocalinux.main --debug
# Pre-commit
pre-commit:
pre-commit run --all-files
# Version info
version:
@python -c "from src.vocalinux.version import __version__; print(__version__)"
+132 -83
View File
@@ -1,153 +1,202 @@
# <img src="https://github.com/user-attachments/assets/56dabe5c-5c65-44d5-a36a-429c9fea0719" width="50" height="50"> Vocalinux
# <img src="https://github.com/user-attachments/assets/56dabe5c-5c65-44d5-a36a-429c9fea0719" width="50" height="50"> Vocalinux
[![Vocalinux CI](https://github.com/jatinkrmalik/vocalinux/workflows/Vocalinux%20CI/badge.svg)](https://github.com/jatinkrmalik/vocalinux/actions?query=workflow%3A%22Vocalinux+CI%22)
[![Vocalinux CI](https://github.com/jatinkrmalik/vocalinux/workflows/Vocalinux%20CI/badge.svg)](https://github.com/jatinkrmalik/vocalinux/actions)
[![codecov](https://codecov.io/gh/jatinkrmalik/vocalinux/branch/main/graph/badge.svg)](https://codecov.io/gh/jatinkrmalik/vocalinux)
[![Status: Early Development](https://img.shields.io/badge/Status-Early%20Development-orange)](https://github.com/jatinkrmalik/vocalinux)
[![Status: Alpha](https://img.shields.io/badge/Status-Alpha-orange)](https://github.com/jatinkrmalik/vocalinux)
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
[![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/)
[![Contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg)](https://github.com/jatinkrmalik/vocalinux/issues)
[![Contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg)](CONTRIBUTING.md)
[![GitHub issues](https://img.shields.io/github/issues/jatinkrmalik/vocalinux)](https://github.com/jatinkrmalik/vocalinux/issues)
[![GitHub release](https://img.shields.io/github/v/release/jatinkrmalik/vocalinux?include_prereleases)](https://github.com/jatinkrmalik/vocalinux/releases)
![Vocalinux Users](https://github.com/user-attachments/assets/e3d8dd16-3d4f-408c-b899-93d85e98b107)
A seamless voice dictation system for Linux, comparable to the built-in solutions on macOS and Windows.
**A seamless voice dictation system for Linux**, comparable to the built-in solutions on macOS and Windows.
## Overview
> 🎉 **Alpha Release!** We're excited to share Vocalinux with the community. Try it out and [let us know what you think](https://github.com/jatinkrmalik/vocalinux/issues)!
Vocalinux provides a user-friendly speech-to-text solution for Linux users with:
---
- Activation via double-tap Ctrl keyboard shortcut
- Real-time transcription with minimal latency
- Universal compatibility across applications
- Offline operation for privacy and reliability
- Visual indicators for microphone status
- Audio feedback for recognition status
## ✨ Features
## Quick Start
- 🎤 **Double-tap Ctrl** to start/stop voice dictation
- ⚡ **Real-time transcription** with minimal latency
- �� **Universal compatibility** across all Linux applications
- 🔒 **Offline operation** for privacy and reliability (with VOSK)
- 🤖 **Optional Whisper AI** support for enhanced accuracy
- 🎨 **System tray integration** with visual status indicators
- 🔊 **Audio feedback** for recording status
- ⚙️ **Graphical settings** dialog for easy configuration
### Prerequisites
## 🚀 Quick Install
- Ubuntu 22.04 or newer (may work on other Linux distributions)
- Python 3.8 or newer
- X11 or Wayland desktop environment
### One-liner Installation (Recommended)
### Installation
\`\`\`bash
git clone https://github.com/jatinkrmalik/vocalinux.git && cd vocalinux && ./install.sh
\`\`\`
```bash
### Or step by step:
\`\`\`bash
# Clone the repository
git clone https://github.com/jatinkrmalik/vocalinux.git
cd vocalinux
# Run the installer script
# Run the installer
./install.sh
```
\`\`\`
The installer will set up everything you need, including system dependencies and a Python virtual environment.
The installer handles everything: system dependencies, Python environment, speech models, and desktop integration.
For detailed installation instructions and options, see [docs/INSTALL.md](docs/INSTALL.md).
### After Installation
### Running Vocalinux
After installation:
```bash
\`\`\`bash
# Activate the virtual environment
source activate-vocalinux.sh
# Run Vocalinux
vocalinux
```
\`\`\`
## Usage
Or launch it from your application menu!
## 📋 Requirements
- **OS**: Ubuntu 22.04+ (other Linux distros may work)
- **Python**: 3.8 or newer
- **Display**: X11 or Wayland
- **Hardware**: Microphone for voice input
## 🎙️ Usage
### Voice Dictation
1. Double-tap the Ctrl key to start recording
1. **Double-tap Ctrl** to start recording
2. Speak clearly into your microphone
3. Double-tap Ctrl again or pause speaking to stop recording
3. **Double-tap Ctrl** again (or pause speaking) to stop
### Voice Commands
| Command | Action |
|---------|--------|
| "new line" | Inserts a line break |
| "period" | Types a period (.) |
| "period" / "full stop" | Types a period (.) |
| "comma" | Types a comma (,) |
| "question mark" | Types a question mark (?) |
| "exclamation mark" | Types an exclamation mark (!) |
| "delete that" | Deletes the last sentence |
| "capitalize" | Capitalizes the next word |
For a complete user guide, see [docs/USER_GUIDE.md](docs/USER_GUIDE.md).
### Command Line Options
## Configuration
\`\`\`bash
vocalinux --help # Show all options
vocalinux --debug # Enable debug logging
vocalinux --engine whisper # Use Whisper AI engine
vocalinux --model medium # Use medium-sized model
vocalinux --wayland # Force Wayland mode
\`\`\`
Configuration is stored in `~/.config/vocalinux/config.json`. You can customize:
## ⚙️ Configuration
- Recognition engine (VOSK or Whisper)
- Model size (small, medium, large)
- Keyboard shortcuts
- Audio feedback
- UI behavior
- And more
Configuration is stored in \`~/.config/vocalinux/config.json\`:
## Features
\`\`\`json
{
"speech_recognition": {
"engine": "vosk",
"model_size": "small",
"vad_sensitivity": 3,
"silence_timeout": 2.0
}
}
\`\`\`
### Custom Icons
You can also configure settings through the graphical Settings dialog (right-click the tray icon).
Vocalinux includes custom icons for system trays and application launchers that visually indicate:
- Microphone inactive state
- Microphone active state
- Speech processing state
## 🔧 Development Setup
### Custom Sounds
\`\`\`bash
# Clone and install in dev mode
git clone https://github.com/jatinkrmalik/vocalinux.git
cd vocalinux
./install.sh --dev
Audio feedback is provided for:
- Start of recording
- End of recording
- Error conditions
# Activate environment
source activate-vocalinux.sh
You can customize these sounds by replacing the files in `resources/sounds/`.
# Run tests
pytest
## Advanced Usage
# Run from source with debug
python -m vocalinux.main --debug
\`\`\`
```bash
# With debugging enabled
vocalinux --debug
## 📁 Project Structure
# With a specific speech recognition engine
vocalinux --engine whisper
\`\`\`
vocalinux/
├── src/vocalinux/ # Main application code
│ ├── speech_recognition/ # Speech recognition engines
│ ├── text_injection/ # Text injection (X11/Wayland)
│ ├── ui/ # GTK UI components
│ └── utils/ # Utility functions
├── tests/ # Test suite
├── resources/ # Icons and sounds
├── docs/ # Documentation
└── web/ # Website source
\`\`\`
# With a specific model size
vocalinux --model medium
# Force Wayland compatibility mode
vocalinux --wayland
```
## Documentation
## 📖 Documentation
- [Installation Guide](docs/INSTALL.md) - Detailed installation instructions
- [User Guide](docs/USER_GUIDE.md) - Complete user documentation
- [Contributing](CONTRIBUTING.md) - Development setup and contribution guidelines
## Roadmap
## 🗺️ Roadmap
Future development plans include:
- [x] ~~Custom icon design~~ ✅
- [x] ~~Graphical settings dialog~~ ✅
- [x] ~~Whisper AI support~~ ✅
- [ ] Multi-language support
- [ ] Application-specific commands
- [ ] Debian/Ubuntu package (.deb)
- [ ] Improved Wayland support
- [ ] Voice command customization
1. ~~Custom icon design~~ ✅ (Implemented in v0.1.0)
2. Graphical settings dialog
3. Advanced voice commands for specific applications
4. Multi-language support
5. Better integration with popular applications
6. Improved model management
7. Customizable keyboard shortcuts via GUI
8. More audio feedback options
## 🤝 Contributing
## Contributing
We welcome contributions! Whether it's bug reports, feature requests, or code contributions, please check out our [Contributing Guide](CONTRIBUTING.md).
Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for development setup instructions and contribution guidelines.
### Quick Links
## License
- 🐛 [Report a Bug](https://github.com/jatinkrmalik/vocalinux/issues/new?template=bug_report.md)
- 💡 [Request a Feature](https://github.com/jatinkrmalik/vocalinux/issues/new?template=feature_request.md)
- 💬 [Discussions](https://github.com/jatinkrmalik/vocalinux/discussions)
This project is licensed under the GNU General Public License v3.0 - see the [LICENSE](LICENSE) file for details.
## 👨‍💻 Author
**Jatin K Malik**
- GitHub: [@jatinkrmalik](https://github.com/jatinkrmalik)
- Twitter: [@jatinkrmalik](https://twitter.com/jatinkrmalik)
## ⭐ Support
If you find Vocalinux useful, please consider:
- ⭐ Starring this repository
- 🐛 Reporting bugs you encounter
- 📖 Improving documentation
- 🔀 Contributing code
## 📜 License
This project is licensed under the **GNU General Public License v3.0** - see the [LICENSE](LICENSE) file for details.
---
<p align="center">
Made with ❤️ for the Linux community
<br>
<a href="https://github.com/jatinkrmalik/vocalinux">github.com/jatinkrmalik/vocalinux</a>
</p>
+83
View File
@@ -0,0 +1,83 @@
# Security Policy
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| 0.2.x | :white_check_mark: |
| < 0.2 | :x: |
## Reporting a Vulnerability
We take security seriously. If you discover a security vulnerability within Vocalinux, please follow these steps:
### Do NOT
- Open a public GitHub issue for security vulnerabilities
- Disclose the vulnerability publicly before it's fixed
### Do
1. **Email the maintainer directly**: Send an email to jatinkrmalik@gmail.com with:
- A clear description of the vulnerability
- Steps to reproduce the issue
- Potential impact assessment
- Any suggested fixes (optional)
2. **Use the subject line**: "Vocalinux Security Vulnerability Report"
3. **Wait for acknowledgment**: You should receive a response within 48 hours
### What to Expect
1. **Acknowledgment**: We'll confirm receipt within 48 hours
2. **Assessment**: We'll evaluate the severity and impact
3. **Timeline**: We'll provide an estimated fix timeline
4. **Fix**: We'll work on a fix and coordinate disclosure
5. **Credit**: If desired, we'll credit you in the release notes
## Security Considerations
### Privacy
Vocalinux is designed with privacy in mind:
- **Offline by default**: The VOSK engine works completely offline
- **Local processing**: All speech recognition happens on your device
- **No data collection**: We don't collect or transmit your voice data
- **No telemetry**: We don't track usage or behavior
### Whisper AI
When using Whisper, be aware:
- Whisper processes locally on your machine
- Models are downloaded once and cached locally
- No audio data is sent to external servers
### File Permissions
The application stores data in:
- `~/.config/vocalinux/` - Configuration (mode 700)
- `~/.local/share/vocalinux/` - Data and models (mode 700)
### Known Limitations
1. **Text injection**: Uses xdotool/wtype which may have implications for input monitoring
2. **Keyboard shortcuts**: Global keyboard hooks require appropriate permissions
3. **Virtual environments**: Running in a venv provides some isolation
## Updates
Keep your installation updated to receive security fixes:
```bash
cd vocalinux
git pull origin main
pip install --upgrade -e .
```
## Contact
For security concerns: jatinkrmalik@gmail.com
For general issues: https://github.com/jatinkrmalik/vocalinux/issues
+218 -105
View File
@@ -2,50 +2,84 @@
This guide provides detailed instructions for installing Vocalinux on Linux systems.
## Quick Start
### One-liner Installation
```bash
git clone https://github.com/jatinkrmalik/vocalinux.git && cd vocalinux && ./install.sh
```
That's it! The installer handles everything automatically.
## System Requirements
- **Operating System**: Ubuntu 22.04 or newer (may work on other Linux distributions)
- **Python**: Version 3.8 or newer
- **Display Server**: X11 or Wayland desktop environment
- **Hardware**: Microphone for speech input
| Requirement | Details |
|-------------|---------|
| **Operating System** | Ubuntu 22.04+ (recommended), Debian 11+, Fedora 38+, Arch Linux |
| **Python** | 3.8 or newer |
| **Display Server** | X11 or Wayland |
| **Hardware** | Microphone for speech input |
| **Disk Space** | ~500MB (including speech models) |
| **RAM** | 4GB minimum, 8GB recommended for Whisper |
## Standard Installation
## Installation Options
The recommended way to install Vocalinux is using the provided installation script:
### Standard Installation
```bash
# Clone the repository
git clone https://github.com/jatinkrmalik/vocalinux.git
cd vocalinux
# Run the installer script
# Run the installer
./install.sh
```
### What the Installer Does
### Installation with Whisper Support
The installation script:
1. Installs required system dependencies
2. Creates a Python virtual environment
3. Configures application directories
4. Installs custom icons and desktop entries
5. Sets up everything you need to run Vocalinux
Whisper provides better accuracy but requires more resources:
### Installation Options
```bash
./install.sh --with-whisper
```
The installer script supports several options:
### Development Installation
For contributing or development work:
```bash
./install.sh --dev
```
This installs additional tools: pytest, black, isort, flake8, pre-commit.
### All Installer Options
```bash
# See all available options
./install.sh --help
# Specify a custom virtual environment directory
./install.sh --venv-dir=custom_venv_name
Options:
--dev Install in development mode with all dev dependencies
--test Run tests after installation
--venv-dir=PATH Specify custom virtual environment directory (default: venv)
--skip-models Skip downloading VOSK models during installation
--with-whisper Install Whisper AI support (requires more disk space)
--help Show this help message
```
## What the Installer Does
1. **Detects your Linux distribution** and installs appropriate system packages
2. **Creates a Python virtual environment** with system site-packages access
3. **Installs the Vocalinux package** and all dependencies
4. **Downloads speech recognition models** (VOSK small model by default)
5. **Installs desktop integration** (icons, .desktop file)
6. **Creates activation script** for easy environment activation
## Running Vocalinux
After installation, you need to activate the virtual environment before using Vocalinux:
After installation:
```bash
# Activate the virtual environment
@@ -55,151 +89,230 @@ source activate-vocalinux.sh
vocalinux
```
You can also launch Vocalinux from your application menu.
## Command Line Options
Vocalinux supports several command-line options:
### Command Line Options
```bash
# With debugging enabled
vocalinux --debug
# With a specific speech recognition engine
vocalinux --engine whisper
# With a specific model size
vocalinux --model medium
# Force Wayland compatibility mode
vocalinux --wayland
vocalinux --help # Show all options
vocalinux --debug # Enable debug logging
vocalinux --engine whisper # Use Whisper AI engine
vocalinux --engine vosk # Use VOSK engine (default)
vocalinux --model small # Use small model
vocalinux --model medium # Use medium model
vocalinux --model large # Use large model
vocalinux --wayland # Force Wayland compatibility mode
```
## Directory Structure
Vocalinux follows the [XDG Base Directory Specification](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html) and stores files in these locations:
Vocalinux follows the [XDG Base Directory Specification](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html):
- `~/.config/vocalinux/` - Configuration files
- `~/.local/share/vocalinux/` - Application data (models, etc.)
- `~/.local/share/applications/` - Desktop entry
- `~/.local/share/icons/hicolor/scalable/apps/` - System-wide icons
| Directory | Purpose |
|-----------|---------|
| `~/.config/vocalinux/` | Configuration files |
| `~/.local/share/vocalinux/` | Application data, speech models |
| `~/.local/share/applications/` | Desktop entry |
| `~/.local/share/icons/hicolor/scalable/apps/` | Application icons |
## Manual Installation
If you prefer to install manually:
If you prefer manual installation or the automatic installer doesn't work:
### 1. Install System Dependencies
**Ubuntu/Debian:**
```bash
# Required system dependencies
sudo apt update
sudo apt install -y python3-pip python3-gi python3-gi-cairo gir1.2-gtk-3.0 \
gir1.2-appindicator3-0.1 libgirepository1.0-dev python3-dev portaudio19-dev python3-venv
sudo apt install -y \
python3-pip python3-venv python3-dev \
python3-gi python3-gi-cairo \
gir1.2-gtk-3.0 gir1.2-appindicator3-0.1 \
libgirepository1.0-dev portaudio19-dev \
wget curl unzip
# For X11 environments
# For X11
sudo apt install -y xdotool
# For Wayland environments
# For Wayland
sudo apt install -y wtype
```
### 2. Set Up Python Environment
**Fedora:**
```bash
sudo dnf install -y \
python3-pip python3-devel python3-virtualenv \
python3-gobject gtk3 libappindicator-gtk3 \
gobject-introspection-devel portaudio-devel \
wget curl unzip xdotool
```
**Arch Linux:**
```bash
sudo pacman -S --noconfirm \
python-pip python-gobject gtk3 \
libappindicator-gtk3 gobject-introspection \
python-cairo portaudio python-virtualenv \
wget curl unzip xdotool
```
### 2. Create Virtual Environment
```bash
# Create a virtual environment
cd vocalinux
python3 -m venv venv --system-site-packages
source venv/bin/activate
# Update pip and setuptools
pip install --upgrade pip setuptools wheel
```
### 3. Install Python Package
### 3. Install Package
```bash
# Basic installation
# Standard installation
pip install .
# With Whisper support (requires more resources)
# With Whisper support
pip install ".[whisper]"
# Development mode
pip install -e ".[dev]"
```
### 4. Install Icons and Desktop Entry
### 4. Set Up Desktop Integration
```bash
# Define XDG directories
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/vocalinux"
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/vocalinux"
DESKTOP_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/applications"
ICON_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/scalable/apps"
# Create directories
mkdir -p ~/.config/vocalinux
mkdir -p ~/.local/share/vocalinux/models
mkdir -p ~/.local/share/applications
mkdir -p ~/.local/share/icons/hicolor/scalable/apps
# Create necessary directories
mkdir -p "$CONFIG_DIR"
mkdir -p "$DATA_DIR/models"
mkdir -p "$DESKTOP_DIR"
mkdir -p "$ICON_DIR"
# Install desktop entry
cp vocalinux.desktop ~/.local/share/applications/
VENV_PATH=$(realpath venv/bin/vocalinux)
sed -i "s|^Exec=vocalinux|Exec=$VENV_PATH|" ~/.local/share/applications/vocalinux.desktop
# Copy desktop entry and update to use the venv
cp vocalinux.desktop "$DESKTOP_DIR/"
VENV_SCRIPT_PATH="$(realpath venv/bin/vocalinux)"
sed -i "s|^Exec=vocalinux|Exec=$VENV_SCRIPT_PATH|" "$DESKTOP_DIR/vocalinux.desktop"
# Copy icons
cp resources/icons/scalable/vocalinux.svg "$ICON_DIR/"
cp resources/icons/scalable/vocalinux-microphone.svg "$ICON_DIR/"
cp resources/icons/scalable/vocalinux-microphone-off.svg "$ICON_DIR/"
cp resources/icons/scalable/vocalinux-microphone-process.svg "$ICON_DIR/"
# Install icons
cp resources/icons/scalable/*.svg ~/.local/share/icons/hicolor/scalable/apps/
# Update icon cache
gtk-update-icon-cache -f -t "${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor" 2>/dev/null || true
gtk-update-icon-cache -f -t ~/.local/share/icons/hicolor
```
## Troubleshooting
### Virtual Environment Issues
If you encounter problems with the virtual environment:
- Ensure you've activated it with `source activate-vocalinux.sh`
- If you get "command not found" errors, verify the virtual environment has been properly created
**Symptom:** "command not found: vocalinux"
**Solution:**
```bash
# Make sure you've activated the environment
source activate-vocalinux.sh
# Or activate directly
source venv/bin/activate
```
### Audio Input Problems
- Check that your microphone is connected and working
- Verify your system audio settings
- Run with `--debug` flag to see detailed logs
**Symptom:** "No audio detected" or microphone not working
### Text Injection Issues
**Solutions:**
1. Check system audio settings
2. Run `arecord -l` to list audio devices
3. Try with debug mode: `vocalinux --debug`
4. Check microphone permissions
- For X11: Ensure xdotool is installed
- For Wayland: Ensure wtype is installed
- Some applications may have security measures that prevent text injection
### GTK/AppIndicator Errors
**Symptom:** "No module named gi" or AppIndicator errors
**Solution:**
```bash
# Reinstall GTK dependencies
sudo apt install python3-gi python3-gi-cairo gir1.2-appindicator3-0.1
# Recreate venv with system packages
rm -rf venv
python3 -m venv venv --system-site-packages
source venv/bin/activate
pip install -e .
```
### Text Injection Not Working
**Symptom:** Recognized text doesn't appear in applications
**Solutions:**
For X11:
```bash
sudo apt install xdotool
# Test: xdotool type "hello"
```
For Wayland:
```bash
sudo apt install wtype
# Test: wtype "hello"
```
### Model Download Fails
**Symptom:** Can't download speech models
**Solution:**
```bash
# Models will auto-download on first run
# Or manually download:
mkdir -p ~/.local/share/vocalinux/models
cd ~/.local/share/vocalinux/models
wget https://alphacephei.com/vosk/models/vosk-model-small-en-us-0.15.zip
unzip vosk-model-small-en-us-0.15.zip
```
### Icons Not Displaying
- Run `gtk-update-icon-cache -f -t ~/.local/share/icons/hicolor` to refresh the icon cache
- Ensure the SVG icons are properly installed in the correct directory
**Symptom:** Tray icon missing or generic
### Recognition Accuracy
**Solution:**
```bash
# Refresh icon cache
gtk-update-icon-cache -f -t ~/.local/share/icons/hicolor
- Try a larger model size (`--model medium` or `--model large`)
- Ensure your microphone is positioned correctly
- Speak clearly and at a moderate pace
# Restart the application
```
## Uninstallation
To uninstall Vocalinux:
### Using the Uninstaller
1. Remove the application directories:
```bash
rm -rf ~/.config/vocalinux
rm -rf ~/.local/share/vocalinux
rm ~/.local/share/applications/vocalinux.desktop
rm ~/.local/share/icons/hicolor/scalable/apps/vocalinux*.svg
```
```bash
./uninstall.sh
```
2. Update the icon cache:
```bash
gtk-update-icon-cache -f -t ~/.local/share/icons/hicolor
```
Options:
- `--keep-config` - Keep configuration files
- `--keep-data` - Keep application data (models, etc.)
3. Remove the cloned repository and virtual environment (if you wish).
### Manual Uninstallation
```bash
# Remove application files
rm -rf venv
rm -f activate-vocalinux.sh
# Remove user data
rm -rf ~/.config/vocalinux
rm -rf ~/.local/share/vocalinux
rm -f ~/.local/share/applications/vocalinux.desktop
rm -f ~/.local/share/icons/hicolor/scalable/apps/vocalinux*.svg
# Update icon cache
gtk-update-icon-cache -f -t ~/.local/share/icons/hicolor
```
## Getting Help
- 📖 [User Guide](USER_GUIDE.md)
- 🐛 [Report Issues](https://github.com/jatinkrmalik/vocalinux/issues)
- 💬 [Discussions](https://github.com/jatinkrmalik/vocalinux/discussions)
+153
View File
@@ -0,0 +1,153 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "vocalinux"
dynamic = ["version"]
description = "A seamless voice dictation system for Linux"
readme = "README.md"
license = {text = "GPL-3.0"}
authors = [
{name = "Jatin K Malik", email = "jatinkrmalik@gmail.com"}
]
maintainers = [
{name = "Jatin K Malik", email = "jatinkrmalik@gmail.com"}
]
requires-python = ">=3.8"
keywords = [
"voice", "dictation", "speech", "recognition",
"linux", "accessibility", "voice-typing", "speech-to-text"
]
classifiers = [
"Development Status :: 3 - Alpha",
"Environment :: X11 Applications :: GTK",
"Intended Audience :: End Users/Desktop",
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
"Operating System :: POSIX :: Linux",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Desktop Environment :: Gnome",
"Topic :: Multimedia :: Sound/Audio :: Speech",
"Topic :: Text Processing",
"Topic :: Utilities",
]
dependencies = [
"vosk>=0.3.45",
"pydub>=0.25.1",
"pynput>=1.7.6",
"requests>=2.28.0",
"tqdm>=4.64.0",
"numpy>=1.22.0",
"pyaudio>=0.2.13",
"python-xlib; sys_platform == 'linux'",
"PyGObject; sys_platform == 'linux'",
]
[project.optional-dependencies]
whisper = [
"openai-whisper>=20231117",
"torch>=2.0.0",
"torchaudio>=2.0.0",
]
dev = [
"pytest>=7.0.0",
"pytest-cov>=4.0.0",
"pytest-mock>=3.10.0",
"black>=23.0.0",
"isort>=5.12.0",
"flake8>=6.0.0",
"pre-commit>=3.0.0",
"mypy>=1.0.0",
]
docs = [
"sphinx>=6.0.0",
"sphinx-rtd-theme>=1.2.0",
]
[project.scripts]
vocalinux = "vocalinux.main:main"
[project.gui-scripts]
vocalinux-gui = "vocalinux.main:main"
[project.urls]
Homepage = "https://github.com/jatinkrmalik/vocalinux"
Documentation = "https://github.com/jatinkrmalik/vocalinux/tree/main/docs"
Repository = "https://github.com/jatinkrmalik/vocalinux"
Issues = "https://github.com/jatinkrmalik/vocalinux/issues"
Changelog = "https://github.com/jatinkrmalik/vocalinux/releases"
[tool.setuptools]
packages = ["vocalinux", "vocalinux.speech_recognition", "vocalinux.text_injection", "vocalinux.ui", "vocalinux.utils"]
package-dir = {"" = "src"}
[tool.setuptools.dynamic]
version = {attr = "vocalinux.version.__version__"}
[tool.setuptools.package-data]
"*" = ["*.svg", "*.wav"]
[tool.black]
line-length = 100
target-version = ['py38', 'py39', 'py310', 'py311']
include = '\.pyi?$'
exclude = '''
/(
\.git
| \.hg
| \.mypy_cache
| \.tox
| \.venv
| venv
| _build
| buck-out
| build
| dist
| node_modules
)/
'''
[tool.isort]
profile = "black"
line_length = 100
skip_gitignore = true
skip = [".git", "venv", "node_modules", "build", "dist"]
[tool.pytest.ini_options]
minversion = "7.0"
addopts = "-ra -q --strict-markers"
testpaths = ["tests"]
python_files = ["test_*.py"]
python_functions = ["test_*"]
markers = [
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
"integration: marks tests as integration tests",
"audio: marks tests that require audio hardware",
]
[tool.coverage.run]
source = ["src"]
branch = true
omit = ["*/tests/*", "*/__pycache__/*"]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"raise NotImplementedError",
"if TYPE_CHECKING:",
"if __name__ == .__main__.:",
]
show_missing = true
[tool.mypy]
python_version = "3.8"
warn_return_any = true
warn_unused_configs = true
ignore_missing_imports = true
exclude = ["venv", "build", "dist", "node_modules"]
+72 -60
View File
@@ -1,48 +1,89 @@
#!/usr/bin/env python3
"""
Vocalinux - A seamless voice dictation system for Linux
This setup.py is maintained for backwards compatibility with older pip versions
and editable installs. The primary configuration is in pyproject.toml.
"""
import os
import sys
import platform
from setuptools import find_packages, setup
# Check Python version
MIN_PYTHON_VERSION = (3, 8)
if sys.version_info < MIN_PYTHON_VERSION:
sys.exit(f"Error: Vocalinux requires Python {MIN_PYTHON_VERSION[0]}.{MIN_PYTHON_VERSION[1]} or higher")
sys.exit(
f"Error: Vocalinux requires Python {MIN_PYTHON_VERSION[0]}.{MIN_PYTHON_VERSION[1]} or higher"
)
# This package requires several system dependencies that must be installed manually:
# For PyAudio: portaudio19-dev (on Ubuntu/Debian)
# For PyGObject: libgirepository1.0-dev, libcairo2-dev, pkg-config, python3-dev
# Get long description from README if available
long_description = ""
if os.path.exists("README.md"):
with open("README.md", "r", encoding="utf-8") as fh:
long_description = fh.read()
def get_version():
"""Get version from version.py file."""
version = "0.2.0-alpha"
version_file = os.path.join(
os.path.dirname(__file__), "src", "vocalinux", "version.py"
)
if os.path.exists(version_file):
with open(version_file, "r", encoding="utf-8") as f:
for line in f:
if line.startswith("__version__"):
version = line.split("=")[1].strip().strip('"').strip("'")
break
return version
# Get version from version file or default to 0.1.0
version = "0.1.0"
if os.path.exists("src/vocalinux/version.py"):
with open("src/vocalinux/version.py", "r", encoding="utf-8") as f:
exec(f.read())
version = locals().get("__version__", "0.1.0")
# Platform-specific dependencies
platform_dependencies = []
if platform.system() == "Linux":
platform_dependencies.extend([
"python-xlib", # For keyboard shortcut handling in X11
"PyGObject", # For GTK UI
])
def get_long_description():
"""Get long description from README."""
readme_path = os.path.join(os.path.dirname(__file__), "README.md")
if os.path.exists(readme_path):
with open(readme_path, "r", encoding="utf-8") as f:
return f.read()
return ""
# Core dependencies
INSTALL_REQUIRES = [
"vosk>=0.3.45",
"pydub>=0.25.1",
"pynput>=1.7.6",
"requests>=2.28.0",
"tqdm>=4.64.0",
"numpy>=1.22.0",
"pyaudio>=0.2.13",
"python-xlib",
"PyGObject",
]
# Optional dependencies
EXTRAS_REQUIRE = {
"whisper": [
"openai-whisper>=20231117",
"torch>=2.0.0",
"torchaudio>=2.0.0",
],
"dev": [
"pytest>=7.0.0",
"pytest-cov>=4.0.0",
"pytest-mock>=3.10.0",
"black>=23.0.0",
"isort>=5.12.0",
"flake8>=6.0.0",
"pre-commit>=3.0.0",
"mypy>=1.0.0",
],
"docs": [
"sphinx>=6.0.0",
"sphinx-rtd-theme>=1.2.0",
],
}
setup(
name="vocalinux",
version=version,
version=get_version(),
description="A seamless voice dictation system for Linux",
long_description=long_description,
long_description=get_long_description(),
long_description_content_type="text/markdown",
author="Jatin K Malik",
author_email="jatinkrmalik@gmail.com",
@@ -51,38 +92,12 @@ setup(
"Bug Tracker": "https://github.com/jatinkrmalik/vocalinux/issues",
"Documentation": "https://github.com/jatinkrmalik/vocalinux/tree/main/docs",
"Source Code": "https://github.com/jatinkrmalik/vocalinux",
"Changelog": "https://github.com/jatinkrmalik/vocalinux/releases",
},
packages=find_packages(where="src"),
package_dir={"": "src"},
install_requires=[
"vosk>=0.3.45", # For VOSK API speech recognition
"pydub>=0.25.1", # For audio processing
"pynput>=1.7.6", # For keyboard/mouse events
"requests>=2.28.0", # For downloading models
"tqdm>=4.64.0", # For progress bars during downloads
"numpy>=1.22.0", # For numerical operations
"pyaudio>=0.2.13", # For audio input/output
] + platform_dependencies,
extras_require={
"whisper": [
"openai-whisper>=20231117",
"torch>=2.0.0",
"torchaudio>=2.0.0",
], # Optional Whisper AI support
"dev": [
"pytest>=7.0.0",
"pytest-cov>=4.0.0", # For coverage testing
"pytest-mock>=3.10.0", # For mocking in tests
"black>=23.0.0", # For code formatting
"isort>=5.12.0", # For import sorting
"flake8>=6.0.0", # For linting
"pre-commit>=3.0.0", # For pre-commit hooks
],
"docs": [
"sphinx>=6.0.0",
"sphinx-rtd-theme>=1.2.0",
],
},
install_requires=INSTALL_REQUIRES,
extras_require=EXTRAS_REQUIRE,
entry_points={
"console_scripts": [
"vocalinux=vocalinux.main:main",
@@ -91,9 +106,7 @@ setup(
"vocalinux-gui=vocalinux.main:main",
],
},
# Include custom application icons and sounds in the package
data_files=[
# System-wide icons for desktop integration
(
"share/icons/hicolor/scalable/apps",
[
@@ -103,9 +116,7 @@ setup(
"resources/icons/scalable/vocalinux-microphone-process.svg",
],
),
# Desktop file for application launcher
("share/applications", ["vocalinux.desktop"]),
# Install resources inside the package share directory for runtime discovery
(
"share/vocalinux/resources/icons/scalable",
[
@@ -136,11 +147,12 @@ setup(
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Desktop Environment :: Gnome",
"Topic :: Multimedia :: Sound/Audio :: Speech",
"Topic :: Text Processing",
"Topic :: Utilities",
],
python_requires=">=3.8", # Minimum Python version
zip_safe=False, # Required for PyGObject applications
python_requires=">=3.8",
zip_safe=False,
)
+1 -3
View File
@@ -29,9 +29,7 @@ class SpeechRecognitionManagerProtocol(Protocol):
"""Stop the speech recognition process."""
...
def register_state_callback(
self, callback: Callable[[RecognitionState], None]
) -> None:
def register_state_callback(self, callback: Callable[[RecognitionState], None]) -> None:
"""Register a callback for state changes."""
...
+1 -3
View File
@@ -39,9 +39,7 @@ def parse_arguments():
choices=["vosk", "whisper"],
help="Speech recognition engine to use",
)
parser.add_argument(
"--wayland", action="store_true", help="Force Wayland compatibility mode"
)
parser.add_argument("--wayland", action="store_true", help="Force Wayland compatibility mode")
return parser.parse_args()
@@ -83,25 +83,19 @@ class CommandProcessor:
"""Compile regex patterns for command matching."""
# Create regex pattern for text commands
text_cmd_pattern = (
r"\b("
+ "|".join(re.escape(cmd) for cmd in self.text_commands.keys())
+ r")\b"
r"\b(" + "|".join(re.escape(cmd) for cmd in self.text_commands.keys()) + r")\b"
)
self.text_cmd_regex = re.compile(text_cmd_pattern, re.IGNORECASE)
# Create regex pattern for action commands
action_cmd_pattern = (
r"\b("
+ "|".join(re.escape(cmd) for cmd in self.action_commands.keys())
+ r")\b"
r"\b(" + "|".join(re.escape(cmd) for cmd in self.action_commands.keys()) + r")\b"
)
self.action_cmd_regex = re.compile(action_cmd_pattern, re.IGNORECASE)
# Create regex pattern for format commands
format_cmd_pattern = (
r"\b("
+ "|".join(re.escape(cmd) for cmd in self.format_commands.keys())
+ r")\b"
r"\b(" + "|".join(re.escape(cmd) for cmd in self.format_commands.keys()) + r")\b"
)
self.format_cmd_regex = re.compile(format_cmd_pattern, re.IGNORECASE)
@@ -238,9 +232,7 @@ class CommandProcessor:
actions.append(action)
# Check if there's text after the command
match = re.search(
r"\b" + re.escape(cmd) + r"\s+(.*?)\b", text, re.IGNORECASE
)
match = re.search(r"\b" + re.escape(cmd) + r"\s+(.*?)\b", text, re.IGNORECASE)
if match:
processed_text = " " + match.group(1)
else:
@@ -281,9 +273,7 @@ class CommandProcessor:
if re.search(cmd_pattern, text, re.IGNORECASE):
# Handle format command that modifies next word
match = re.search(
r"\b" + re.escape(cmd) + r"\s+(\w+)", text, re.IGNORECASE
)
match = re.search(r"\b" + re.escape(cmd) + r"\s+(\w+)", text, re.IGNORECASE)
if match:
word = match.group(1)
if format_type == "capitalize_next":
@@ -53,6 +53,22 @@ from .command_processor import CommandProcessor
logger = logging.getLogger(__name__)
def _show_notification(title: str, message: str, icon: str = "dialog-warning"):
"""Show a desktop notification."""
try:
import subprocess
# Use notify-send which is available on most Linux desktops
subprocess.Popen(
["notify-send", "-i", icon, "-a", "Vocalinux", title, message],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except Exception as e:
logger.debug(f"Could not show notification: {e}")
# Define constants
MODELS_DIR = os.path.expanduser("~/.local/share/vocalinux/models")
# Alternative locations for pre-installed models
@@ -70,13 +86,16 @@ class SpeechRecognitionManager:
speech recognition engines (VOSK and Whisper).
"""
def __init__(self, engine: str = "vosk", model_size: str = "small", **kwargs):
def __init__(
self, engine: str = "vosk", model_size: str = "small", defer_download: bool = True, **kwargs
):
"""
Initialize the speech recognition manager.
Args:
engine: The speech recognition engine to use ("vosk" or "whisper")
model_size: The size of the model to use ("small", "medium", "large")
defer_download: If True, don't download missing models at startup (default: True)
"""
self.engine = engine
self.model_size = model_size
@@ -90,6 +109,12 @@ class SpeechRecognitionManager:
self.state_callbacks: List[Callable[[RecognitionState], None]] = []
self.action_callbacks: List[Callable[[str], None]] = []
# Download progress tracking
self._download_progress_callback: Optional[Callable[[float, float, str], None]] = None
self._download_cancelled = False
self._defer_download = defer_download
self._model_initialized = False
# Speech detection parameters (load defaults, will be overridden by configure)
self.vad_sensitivity = kwargs.get("vad_sensitivity", 3)
self.silence_timeout = kwargs.get("silence_timeout", 2.0)
@@ -101,9 +126,7 @@ class SpeechRecognitionManager:
# Create models directory if it doesn't exist
os.makedirs(MODELS_DIR, exist_ok=True)
logger.info(
f"Initializing speech recognition with {engine} engine and {model_size} model"
)
logger.info(f"Initializing speech recognition with {engine} engine and {model_size} model")
# Initialize the selected speech recognition engine
if engine == "vosk":
@@ -121,31 +144,25 @@ class SpeechRecognitionManager:
self.vosk_model_path = self._get_vosk_model_path()
if not os.path.exists(self.vosk_model_path):
logger.info(
f"VOSK model not found at {self.vosk_model_path}. Downloading..."
)
self._download_vosk_model()
# Update path after download
self.vosk_model_path = self._get_vosk_model_path()
if self._defer_download:
logger.info(
f"VOSK model not found at {self.vosk_model_path}. Will download when needed."
)
self._model_initialized = False
return # Don't block startup
else:
logger.info(f"VOSK model not found at {self.vosk_model_path}. Downloading...")
self._download_vosk_model()
# Update path after download
self.vosk_model_path = self._get_vosk_model_path()
else:
# Check if this is a pre-installed model
if any(
self.vosk_model_path.startswith(sys_dir)
for sys_dir in SYSTEM_MODELS_DIRS
):
logger.info(
f"Using pre-installed VOSK model from {self.vosk_model_path}"
)
elif os.path.exists(
os.path.join(self.vosk_model_path, ".vocalinux_preinstalled")
):
logger.info(
f"Using installer-provided VOSK model from {self.vosk_model_path}"
)
if any(self.vosk_model_path.startswith(sys_dir) for sys_dir in SYSTEM_MODELS_DIRS):
logger.info(f"Using pre-installed VOSK model from {self.vosk_model_path}")
elif os.path.exists(os.path.join(self.vosk_model_path, ".vocalinux_preinstalled")):
logger.info(f"Using installer-provided VOSK model from {self.vosk_model_path}")
else:
logger.info(
f"Using existing VOSK model from {self.vosk_model_path}"
)
logger.info(f"Using existing VOSK model from {self.vosk_model_path}")
logger.info(f"Loading VOSK model from {self.vosk_model_path}")
# Ensure previous model/recognizer are released if re-initializing
@@ -153,12 +170,11 @@ class SpeechRecognitionManager:
self.recognizer = None
self.model = Model(self.vosk_model_path)
self.recognizer = KaldiRecognizer(self.model, 16000)
self._model_initialized = True
logger.info("VOSK engine initialized successfully.")
except ImportError:
logger.error(
"Failed to import VOSK. Please install it with 'pip install vosk'"
)
logger.error("Failed to import VOSK. Please install it with 'pip install vosk'")
self.state = RecognitionState.ERROR
raise
@@ -183,14 +199,31 @@ class SpeechRecognitionManager:
)
self.model_size = "base"
# Check if model is downloaded
whisper_cache_dir = os.path.join(MODELS_DIR, "whisper")
os.makedirs(whisper_cache_dir, exist_ok=True)
model_file = os.path.join(whisper_cache_dir, f"{self.model_size}.pt")
default_cache = os.path.expanduser("~/.cache/whisper")
default_model_file = os.path.join(default_cache, f"{self.model_size}.pt")
model_exists = os.path.exists(model_file) or os.path.exists(default_model_file)
if not model_exists and self._defer_download:
logger.info(
f"Whisper model '{self.model_size}' not found. Will download when needed."
)
self._model_initialized = False
return # Don't block startup
# If model doesn't exist and we're not deferring, download it with progress
if not model_exists:
logger.info(f"Downloading Whisper '{self.model_size}' model...")
self._download_whisper_model(whisper_cache_dir)
# Determine device (GPU if available, otherwise CPU)
device = "cuda" if torch.cuda.is_available() else "cpu"
logger.info(f"Using device: {device}")
# Download directory for Whisper models
whisper_cache_dir = os.path.join(MODELS_DIR, "whisper")
os.makedirs(whisper_cache_dir, exist_ok=True)
logger.info(f"Loading Whisper '{self.model_size}' model...")
# Ensure previous model is released if re-initializing
self.model = None
@@ -200,6 +233,7 @@ class SpeechRecognitionManager:
self.model_size, device=device, download_root=whisper_cache_dir
)
self._model_initialized = True
logger.info(f"Whisper model loaded on {device.upper()}")
logger.info("Whisper engine initialized successfully.")
@@ -298,12 +332,30 @@ class SpeechRecognitionManager:
logger.debug(f"No existing model found, will use: {user_model_path}")
return user_model_path
def set_download_progress_callback(
self, callback: Optional[Callable[[float, float, str], None]]
):
"""
Set a callback for download progress updates.
Args:
callback: Function(progress_fraction, speed_mbps, status_text)
or None to clear
"""
self._download_progress_callback = callback
def cancel_download(self):
"""Request cancellation of the current download."""
self._download_cancelled = True
logger.info("Download cancellation requested")
def _download_vosk_model(self):
"""Download the VOSK model if it doesn't exist."""
import zipfile
import requests
import tqdm
self._download_cancelled = False
model_urls = {
"small": "https://alphacephei.com/vosk/models/vosk-model-small-en-us-0.15.zip",
@@ -324,9 +376,7 @@ class SpeechRecognitionManager:
# Create models directory if it doesn't exist
os.makedirs(MODELS_DIR, exist_ok=True)
logger.info(
f"Downloading VOSK {self.model_size} model to user directory: {model_path}"
)
logger.info(f"Downloading VOSK {self.model_size} model to user directory: {model_path}")
# Download the model
logger.info(f"Downloading VOSK model from {url}")
@@ -335,15 +385,63 @@ class SpeechRecognitionManager:
response.raise_for_status() # Raise an exception for bad status codes (4xx or 5xx)
total_size = int(response.headers.get("content-length", 0))
downloaded_size = 0
start_time = time.time()
last_update_time = start_time
chunk_size = 8192 # 8KB chunks for smoother progress
with open(zip_path, "wb") as f:
for data in tqdm.tqdm(
response.iter_content(chunk_size=1024),
total=total_size // 1024,
unit="KB",
desc=f"Downloading {model_name}", # Add description to progress bar
):
for data in response.iter_content(chunk_size=chunk_size):
if self._download_cancelled:
logger.info("Download cancelled by user")
f.close()
if os.path.exists(zip_path):
os.remove(zip_path)
raise RuntimeError("Download cancelled")
f.write(data)
downloaded_size += len(data)
# Update progress callback
current_time = time.time()
if (
self._download_progress_callback
and (current_time - last_update_time) >= 0.1
):
elapsed = current_time - start_time
if elapsed > 0:
speed_mbps = (downloaded_size / (1024 * 1024)) / elapsed
else:
speed_mbps = 0
if total_size > 0:
progress = downloaded_size / total_size
remaining_mb = (total_size - downloaded_size) / (1024 * 1024)
if speed_mbps > 0:
eta_seconds = remaining_mb / speed_mbps
eta_str = (
f"{int(eta_seconds)}s"
if eta_seconds < 60
else f"{int(eta_seconds / 60)}m {int(eta_seconds % 60)}s"
)
else:
eta_str = "--"
status = f"{downloaded_size / (1024 * 1024):.1f} / {total_size / (1024 * 1024):.1f} MB • {speed_mbps:.1f} MB/s • ETA: {eta_str}"
else:
progress = 0
status = (
f"{downloaded_size / (1024 * 1024):.1f} MB • {speed_mbps:.1f} MB/s"
)
self._download_progress_callback(progress, speed_mbps, status)
last_update_time = current_time
# Also log progress periodically
logger.info(f"Download progress: {progress * 100:.1f}% - {status}")
# Update status for extraction phase
if self._download_progress_callback:
self._download_progress_callback(1.0, 0, "Extracting model...")
# Extract the model
logger.info(f"Extracting VOSK model to {model_path}")
@@ -354,6 +452,10 @@ class SpeechRecognitionManager:
os.remove(zip_path)
logger.info("VOSK model downloaded and extracted successfully")
# Final status
if self._download_progress_callback:
self._download_progress_callback(1.0, 0, "Complete!")
except requests.exceptions.RequestException as e:
logger.error(f"Failed to download VOSK model from {url}: {e}")
# Clean up potentially incomplete download
@@ -367,9 +469,7 @@ class SpeechRecognitionManager:
os.remove(zip_path)
raise RuntimeError("Downloaded VOSK model file is corrupted.")
except Exception as e:
logger.error(
f"An error occurred during VOSK model download/extraction: {e}"
)
logger.error(f"An error occurred during VOSK model download/extraction: {e}")
# Clean up potentially corrupted extraction
if os.path.exists(zip_path):
os.remove(zip_path)
@@ -377,6 +477,111 @@ class SpeechRecognitionManager:
# if os.path.exists(model_path): shutil.rmtree(model_path)
raise
def _download_whisper_model(self, cache_dir: str):
"""Download a Whisper model with progress tracking."""
import hashlib
import requests
self._download_cancelled = False
# Whisper model URLs (from openai-whisper package)
model_urls = {
"tiny": "https://openaipublic.azureedge.net/main/whisper/models/65147644a518d12f04e32d6f3b26facc3f8dd46e5390956a9424a650c0ce22b9/tiny.pt",
"base": "https://openaipublic.azureedge.net/main/whisper/models/ed3a0b6b1c0edf879ad9b11b1af5a0e6ab5db9205f891f668f8b0e6c6326e34e/base.pt",
"small": "https://openaipublic.azureedge.net/main/whisper/models/9ecf779972d90ba49c06d968637d720dd632c55bbf19d441fb42bf17a411e794/small.pt",
"medium": "https://openaipublic.azureedge.net/main/whisper/models/345ae4da62f9b3d59415adc60127b97c714f32e89e936602e85993674d08dcb1/medium.pt",
"large": "https://openaipublic.azureedge.net/main/whisper/models/e5b1a55b89c1367dacf97e3e19bfd829a01529dbfdeefa8caeb59b3f1b81dadb/large-v3.pt",
}
url = model_urls.get(self.model_size)
if not url:
raise ValueError(f"Unknown Whisper model size: {self.model_size}")
model_file = os.path.join(cache_dir, f"{self.model_size}.pt")
temp_file = model_file + ".tmp"
os.makedirs(cache_dir, exist_ok=True)
logger.info(f"Downloading Whisper {self.model_size} model to {model_file}")
logger.info(f"Downloading from {url}")
try:
response = requests.get(url, stream=True)
response.raise_for_status()
total_size = int(response.headers.get("content-length", 0))
downloaded_size = 0
start_time = time.time()
last_update_time = start_time
chunk_size = 8192 # 8KB chunks
with open(temp_file, "wb") as f:
for data in response.iter_content(chunk_size=chunk_size):
if self._download_cancelled:
logger.info("Download cancelled by user")
f.close()
if os.path.exists(temp_file):
os.remove(temp_file)
raise RuntimeError("Download cancelled")
f.write(data)
downloaded_size += len(data)
# Update progress callback
current_time = time.time()
if (
self._download_progress_callback
and (current_time - last_update_time) >= 0.1
):
elapsed = current_time - start_time
if elapsed > 0:
speed_mbps = (downloaded_size / (1024 * 1024)) / elapsed
else:
speed_mbps = 0
if total_size > 0:
progress = downloaded_size / total_size
remaining_mb = (total_size - downloaded_size) / (1024 * 1024)
if speed_mbps > 0:
eta_seconds = remaining_mb / speed_mbps
eta_str = (
f"{int(eta_seconds)}s"
if eta_seconds < 60
else f"{int(eta_seconds / 60)}m {int(eta_seconds % 60)}s"
)
else:
eta_str = "--"
status = f"{downloaded_size / (1024 * 1024):.1f} / {total_size / (1024 * 1024):.1f} MB • {speed_mbps:.1f} MB/s • ETA: {eta_str}"
else:
progress = 0
status = (
f"{downloaded_size / (1024 * 1024):.1f} MB • {speed_mbps:.1f} MB/s"
)
self._download_progress_callback(progress, speed_mbps, status)
last_update_time = current_time
logger.info(f"Download progress: {progress * 100:.1f}% - {status}")
# Rename temp file to final
os.rename(temp_file, model_file)
logger.info("Whisper model downloaded successfully")
if self._download_progress_callback:
self._download_progress_callback(1.0, 0, "Complete!")
except requests.exceptions.RequestException as e:
logger.error(f"Failed to download Whisper model from {url}: {e}")
if os.path.exists(temp_file):
os.remove(temp_file)
raise RuntimeError(f"Failed to download Whisper model: {e}") from e
except Exception as e:
logger.error(f"An error occurred during Whisper model download: {e}")
if os.path.exists(temp_file):
os.remove(temp_file)
raise
def register_text_callback(self, callback: Callable[[str], None]):
"""
Register a callback function that will be called when text is recognized.
@@ -436,12 +641,30 @@ class SpeechRecognitionManager:
for callback in self.state_callbacks:
callback(new_state)
@property
def model_ready(self) -> bool:
"""Check if the model is initialized and ready for recognition."""
return self._model_initialized and self.model is not None
def start_recognition(self):
"""Start the speech recognition process."""
if self.state != RecognitionState.IDLE:
logger.warning(f"Cannot start recognition in current state: {self.state}")
return
# Check if model is ready
if not self.model_ready:
logger.warning(
"Cannot start recognition: model not downloaded. Please download via Settings."
)
play_error_sound()
_show_notification(
"No Speech Model",
"Please open Settings and download a speech recognition model to use dictation.",
"dialog-warning",
)
return
logger.info("Starting speech recognition")
self._update_state(RecognitionState.LISTENING)
@@ -500,9 +723,7 @@ class SpeechRecognitionManager:
import pyaudio
except ImportError as e:
logger.error(f"Failed to import required audio libraries: {e}")
logger.error(
"Please install required dependencies: pip install pyaudio numpy"
)
logger.error("Please install required dependencies: pip install pyaudio numpy")
play_error_sound()
self._update_state(RecognitionState.ERROR)
return
@@ -545,9 +766,7 @@ class SpeechRecognitionManager:
# Ensure vad_sensitivity is treated as integer for calculation
try:
vad_sens = int(self.vad_sensitivity)
threshold = 500 / max(
1, min(5, vad_sens)
) # Use self.vad_sensitivity
threshold = 500 / max(1, min(5, vad_sens)) # Use self.vad_sensitivity
except ValueError:
logger.warning(
f"Invalid VAD sensitivity value: {self.vad_sensitivity}. Using default 3."
@@ -556,9 +775,7 @@ class SpeechRecognitionManager:
if volume < threshold: # Silence
silence_counter += CHUNK / RATE # Convert chunks to seconds
if (
silence_counter > self.silence_timeout
): # Use self.silence_timeout
if silence_counter > self.silence_timeout: # Use self.silence_timeout
logger.debug("Silence detected, processing buffer")
self._update_state(RecognitionState.PROCESSING)
# Process final buffer
@@ -629,6 +846,7 @@ class SpeechRecognitionManager:
model_size: Optional[str] = None,
vad_sensitivity: Optional[int] = None,
silence_timeout: Optional[float] = None,
force_download: bool = True,
**kwargs, # Allow for future expansion
):
"""
@@ -639,6 +857,7 @@ class SpeechRecognitionManager:
model_size: The new model size.
vad_sensitivity: New VAD sensitivity (for VOSK).
silence_timeout: New silence timeout (for VOSK).
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}"
@@ -661,6 +880,9 @@ class SpeechRecognitionManager:
if restart_needed:
logger.info("Engine or model changed, re-initializing...")
# 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
@@ -670,17 +892,15 @@ class SpeechRecognitionManager:
elif self.engine == "whisper":
self._init_whisper()
else:
raise ValueError(
f"Unsupported engine during reconfigure: {self.engine}"
)
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
)
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.")
+31 -39
View File
@@ -64,17 +64,12 @@ class TextInjector:
["wtype", "test"], stderr=subprocess.PIPE, text=True, check=False
)
error_output = result.stderr.lower()
if (
"compositor does not support" in error_output
or result.returncode != 0
):
if "compositor does not support" in error_output or result.returncode != 0:
logger.warning(
f"Wayland compositor does not support virtual keyboard protocol: {error_output}"
)
if shutil.which("xdotool"):
logger.info(
"Automatically switching to XWayland fallback with xdotool"
)
logger.info("Automatically switching to XWayland fallback with xdotool")
self.environment = DesktopEnvironment.WAYLAND_XDOTOOL
else:
logger.error("No fallback text injection method available")
@@ -111,9 +106,7 @@ class TextInjector:
elif "DISPLAY" in os.environ:
return DesktopEnvironment.X11
else:
logger.warning(
"Could not detect desktop environment, defaulting to X11"
)
logger.warning("Could not detect desktop environment, defaulting to X11")
return DesktopEnvironment.X11
def _check_dependencies(self):
@@ -121,9 +114,7 @@ class TextInjector:
if self.environment == DesktopEnvironment.X11:
# Check for xdotool
if not shutil.which("xdotool"):
logger.error(
"xdotool not found. Please install it with: sudo apt install xdotool"
)
logger.error("xdotool not found. Please install it with: sudo apt install xdotool")
raise RuntimeError("Missing required dependency: xdotool")
else:
# Check for wtype or ydotool for Wayland
@@ -188,7 +179,7 @@ class TextInjector:
Args:
text: The text to inject
Returns:
True if injection was successful, False otherwise
"""
@@ -217,12 +208,8 @@ class TextInjector:
self._inject_with_wayland_tool(escaped_text)
except subprocess.CalledProcessError as e:
logger.warning(f"Wayland tool failed: {e}. Falling back to xdotool")
if "compositor does not support" in str(e).lower() and shutil.which(
"xdotool"
):
logger.info(
"Automatically switching to XWayland fallback permanently"
)
if "compositor does not support" in str(e).lower() and shutil.which("xdotool"):
logger.info("Automatically switching to XWayland fallback permanently")
self.environment = DesktopEnvironment.WAYLAND_XDOTOOL
self._inject_with_xdotool(escaped_text)
else:
@@ -233,6 +220,7 @@ class TextInjector:
logger.error(f"Failed to inject text: {e}", exc_info=True)
try:
from ..ui.audio_feedback import play_error_sound
play_error_sound() # Play error sound when text injection fails
except ImportError:
logger.warning("Could not import audio feedback module")
@@ -317,14 +305,16 @@ class TextInjector:
try:
max_retries = 2
logger.debug(f"Starting xdotool injection with {max_retries} max retries")
for retry in range(max_retries + 1):
try:
# Inject in smaller chunks to avoid issues with very long text
chunk_size = 20 # Reduced chunk size for better reliability
total_chunks = (len(text) + chunk_size - 1) // chunk_size
logger.debug(f"Splitting text into {total_chunks} chunks of max {chunk_size} chars")
logger.debug(
f"Splitting text into {total_chunks} chunks of max {chunk_size} chars"
)
for i in range(0, len(text), chunk_size):
chunk = text[i : i + chunk_size]
chunk_num = (i // chunk_size) + 1
@@ -332,7 +322,7 @@ class TextInjector:
# First try with clearmodifiers
cmd = ["xdotool", "type", "--clearmodifiers", chunk]
logger.debug(f"Injecting chunk {chunk_num}/{total_chunks}: '{chunk}'")
result = subprocess.run(
cmd, env=env, check=True, stderr=subprocess.PIPE, text=True, timeout=5
)
@@ -356,7 +346,9 @@ class TextInjector:
raise # Re-raise on final attempt
except subprocess.TimeoutExpired:
if retry < max_retries:
logger.warning(f"Text injection timeout, retrying (attempt {retry+1}/{max_retries})")
logger.warning(
f"Text injection timeout, retrying (attempt {retry+1}/{max_retries})"
)
time.sleep(0.5)
else:
logger.error("Text injection timed out on final attempt")
@@ -397,15 +389,15 @@ class TextInjector:
def _inject_keyboard_shortcut(self, shortcut: str) -> bool:
"""
Inject a keyboard shortcut.
Args:
shortcut: The keyboard shortcut to inject (e.g., "ctrl+z", "ctrl+a")
Returns:
True if injection was successful, False otherwise
"""
logger.debug(f"Injecting keyboard shortcut: {shortcut}")
try:
if (
self.environment == DesktopEnvironment.X11
@@ -421,22 +413,22 @@ class TextInjector:
def _inject_shortcut_with_xdotool(self, shortcut: str) -> bool:
"""
Inject a keyboard shortcut using xdotool.
Args:
shortcut: The keyboard shortcut to inject
Returns:
True if successful, False otherwise
"""
# Create environment with explicit X11 settings for Wayland compatibility
env = os.environ.copy()
if self.environment == DesktopEnvironment.WAYLAND_XDOTOOL:
env["GDK_BACKEND"] = "x11"
env["QT_QPA_PLATFORM"] = "xcb"
if "DISPLAY" not in env or not env["DISPLAY"]:
env["DISPLAY"] = ":0"
try:
cmd = ["xdotool", "key", "--clearmodifiers", shortcut]
subprocess.run(cmd, env=env, check=True, stderr=subprocess.PIPE, text=True)
@@ -449,10 +441,10 @@ class TextInjector:
def _inject_shortcut_with_wayland_tool(self, shortcut: str) -> bool:
"""
Inject a keyboard shortcut using a Wayland-compatible tool.
Args:
shortcut: The keyboard shortcut to inject
Returns:
True if successful, False otherwise
"""
@@ -489,7 +481,7 @@ class TextInjector:
def _log_x11_window_info(self):
"""Log X11 window information."""
env = os.environ.copy()
if self.environment == DesktopEnvironment.WAYLAND_XDOTOOL:
env["GDK_BACKEND"] = "x11"
env["QT_QPA_PLATFORM"] = "xcb"
@@ -505,7 +497,7 @@ class TextInjector:
stderr=subprocess.PIPE,
text=True,
check=True,
timeout=2
timeout=2,
)
window_id = result.stdout.strip()
logger.debug(f"Active window ID: {window_id}")
@@ -518,7 +510,7 @@ class TextInjector:
stderr=subprocess.PIPE,
text=True,
check=True,
timeout=2
timeout=2,
)
window_name = result.stdout.strip()
logger.info(f"Target window: '{window_name}' (ID: {window_id})")
@@ -531,7 +523,7 @@ class TextInjector:
stderr=subprocess.PIPE,
text=True,
check=True,
timeout=2
timeout=2,
)
window_class = result.stdout.strip()
logger.debug(f"Window class: {window_class}")
@@ -544,7 +536,7 @@ class TextInjector:
stderr=subprocess.PIPE,
text=True,
check=True,
timeout=2
timeout=2,
)
window_pid = result.stdout.strip()
logger.debug(f"Window PID: {window_pid}")
+24 -24
View File
@@ -16,21 +16,21 @@ logger = logging.getLogger(__name__)
class ActionHandler:
"""
Handles special voice command actions.
This class processes action commands from the speech recognition system
and performs the appropriate actions like deleting text, undoing, etc.
"""
def __init__(self, text_injector: "TextInjector"):
"""
Initialize the action handler.
Args:
text_injector: The text injector instance for performing actions
"""
self.text_injector = text_injector
self.last_injected_text = ""
# Map actions to handler methods
self.action_handlers = {
"delete_last": self._handle_delete_last,
@@ -44,19 +44,19 @@ class ActionHandler:
"copy": self._handle_copy,
"paste": self._handle_paste,
}
def handle_action(self, action: str) -> bool:
"""
Handle a voice command action.
Args:
action: The action to perform
Returns:
True if the action was handled successfully, False otherwise
"""
logger.debug(f"Handling action: {action}")
handler = self.action_handlers.get(action)
if handler:
try:
@@ -67,64 +67,64 @@ class ActionHandler:
else:
logger.warning(f"Unknown action: {action}")
return False
def set_last_injected_text(self, text: str):
"""
Set the last injected text for undo/delete operations.
Args:
text: The last text that was injected
"""
self.last_injected_text = text
def _handle_delete_last(self) -> bool:
"""Handle 'delete that' command by sending backspace keys."""
if not self.last_injected_text:
logger.debug("No text to delete")
return True
# Send backspace keys for each character in the last injected text
backspaces = "\b" * len(self.last_injected_text)
success = self.text_injector.inject_text(backspaces)
if success:
logger.debug(f"Deleted {len(self.last_injected_text)} characters")
self.last_injected_text = ""
return success
def _handle_undo(self) -> bool:
"""Handle 'undo' command by sending Ctrl+Z."""
return self.text_injector._inject_keyboard_shortcut("ctrl+z")
def _handle_redo(self) -> bool:
"""Handle 'redo' command by sending Ctrl+Y."""
return self.text_injector._inject_keyboard_shortcut("ctrl+y")
def _handle_select_all(self) -> bool:
"""Handle 'select all' command by sending Ctrl+A."""
return self.text_injector._inject_keyboard_shortcut("ctrl+a")
def _handle_select_line(self) -> bool:
"""Handle 'select line' command by sending Home+Shift+End."""
return self.text_injector._inject_keyboard_shortcut("Home+shift+End")
def _handle_select_word(self) -> bool:
"""Handle 'select word' command by sending Ctrl+Shift+Right."""
return self.text_injector._inject_keyboard_shortcut("ctrl+shift+Right")
def _handle_select_paragraph(self) -> bool:
"""Handle 'select paragraph' command by sending Ctrl+Shift+Down."""
return self.text_injector._inject_keyboard_shortcut("ctrl+shift+Down")
def _handle_cut(self) -> bool:
"""Handle 'cut' command by sending Ctrl+X."""
return self.text_injector._inject_keyboard_shortcut("ctrl+x")
def _handle_copy(self) -> bool:
"""Handle 'copy' command by sending Ctrl+C."""
return self.text_injector._inject_keyboard_shortcut("ctrl+c")
def _handle_paste(self) -> bool:
"""Handle 'paste' command by sending Ctrl+V."""
return self.text_injector._inject_keyboard_shortcut("ctrl+v")
return self.text_injector._inject_keyboard_shortcut("ctrl+v")
+1 -5
View File
@@ -159,11 +159,7 @@ class ConfigManager:
source: The source dictionary with updates
"""
for key, value in source.items():
if (
key in target
and isinstance(target[key], dict)
and isinstance(value, dict)
):
if key in target and isinstance(target[key], dict) and isinstance(value, dict):
self._update_dict_recursive(target[key], value)
else:
target[key] = value
+4 -13
View File
@@ -47,9 +47,7 @@ class KeyboardShortcutManager:
self.double_tap_threshold = 0.3 # seconds between taps to count as double-tap
if not KEYBOARD_AVAILABLE:
logger.error(
"Keyboard shortcut libraries not available. Shortcuts will not work."
)
logger.error("Keyboard shortcut libraries not available. Shortcuts will not work.")
return
def start(self):
@@ -68,9 +66,7 @@ class KeyboardShortcutManager:
try:
# Start keyboard listener in a separate thread
self.listener = keyboard.Listener(
on_press=self._on_press, on_release=self._on_release
)
self.listener = keyboard.Listener(on_press=self._on_press, on_release=self._on_release)
self.listener.daemon = True
self.listener.start()
@@ -125,16 +121,11 @@ class KeyboardShortcutManager:
current_time = time.time()
if current_time - self.last_ctrl_press_time < self.double_tap_threshold:
# This is a double-tap Ctrl
if (
self.double_tap_callback
and current_time - self.last_trigger_time > 0.5
):
if self.double_tap_callback and current_time - self.last_trigger_time > 0.5:
logger.debug("Double-tap Ctrl detected")
self.last_trigger_time = current_time
# Run callback in a separate thread to avoid blocking
threading.Thread(
target=self.double_tap_callback, daemon=True
).start()
threading.Thread(target=self.double_tap_callback, daemon=True).start()
self.last_ctrl_press_time = current_time
# Add to currently pressed modifier keys (only for tracking Ctrl)
+116 -116
View File
@@ -23,74 +23,74 @@ logger = logging.getLogger(__name__)
class LoggingDialog(Gtk.Dialog):
"""GTK Dialog for viewing and managing application logs."""
def __init__(self, parent: Optional[Gtk.Window] = None):
super().__init__(
title="VocaLinux Logs",
transient_for=parent,
flags=Gtk.DialogFlags.DESTROY_WITH_PARENT,
modal=False
modal=False,
)
self.logging_manager = get_logging_manager()
self.auto_scroll = True
self.filter_level = None
self.filter_module = None
# Set dialog properties
self.set_default_size(900, 700)
self.set_border_width(10)
# Add buttons
self.add_button("Close", Gtk.ResponseType.CLOSE)
self.add_button("Copy All", Gtk.ResponseType.HELP)
self.add_button("Export", Gtk.ResponseType.APPLY)
self.add_button("Clear", Gtk.ResponseType.REJECT)
# Create UI
self._create_ui()
# Register for new log records
self.logging_manager.register_callback(self._on_new_log_record)
# Load existing logs
self._refresh_logs()
# Connect signals
self.connect("response", self._on_response)
self.connect("destroy", self._on_destroy)
def _create_ui(self):
"""Create the user interface."""
content_area = self.get_content_area()
# Create main container
main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
content_area.add(main_box)
# Create toolbar
toolbar_box = self._create_toolbar()
main_box.pack_start(toolbar_box, False, False, 0)
# Create log view (give it most of the space)
log_view_box = self._create_log_view()
main_box.pack_start(log_view_box, True, True, 0)
# Create status bar
status_box = self._create_status_bar()
main_box.pack_start(status_box, False, False, 0)
# Show all widgets
main_box.show_all()
def _create_toolbar(self):
"""Create the toolbar with filters and controls."""
toolbar_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
# Level filter
level_label = Gtk.Label(label="Level:")
toolbar_box.pack_start(level_label, False, False, 0)
self.level_combo = Gtk.ComboBoxText()
self.level_combo.append("ALL", "All Levels")
self.level_combo.append("DEBUG", "Debug")
@@ -101,29 +101,29 @@ class LoggingDialog(Gtk.Dialog):
self.level_combo.set_active(0)
self.level_combo.connect("changed", self._on_filter_changed)
toolbar_box.pack_start(self.level_combo, False, False, 0)
# Module filter
module_label = Gtk.Label(label="Module:")
toolbar_box.pack_start(module_label, False, False, 0)
self.module_entry = Gtk.Entry()
self.module_entry.set_placeholder_text("Filter by module name...")
self.module_entry.connect("changed", self._on_filter_changed)
toolbar_box.pack_start(self.module_entry, True, True, 0)
# Auto-scroll toggle
self.auto_scroll_check = Gtk.CheckButton(label="Auto-scroll")
self.auto_scroll_check.set_active(True)
self.auto_scroll_check.connect("toggled", self._on_auto_scroll_toggled)
toolbar_box.pack_start(self.auto_scroll_check, False, False, 0)
# Refresh button
refresh_button = Gtk.Button(label="Refresh")
refresh_button.connect("clicked", self._on_refresh_clicked)
toolbar_box.pack_start(refresh_button, False, False, 0)
return toolbar_box
def _create_log_view(self):
"""Create the main log viewing area."""
# Create scrolled window
@@ -134,7 +134,7 @@ class LoggingDialog(Gtk.Dialog):
scrolled.set_min_content_height(400)
scrolled.set_vexpand(True)
scrolled.set_hexpand(True)
# Create text view
self.text_view = Gtk.TextView()
self.text_view.set_editable(False)
@@ -143,189 +143,188 @@ class LoggingDialog(Gtk.Dialog):
# Ensure the text view expands to fill available space
self.text_view.set_vexpand(True)
self.text_view.set_hexpand(True)
# Set monospace font
font_desc = self.text_view.get_pango_context().get_font_description()
font_desc.set_family("monospace")
font_desc.set_size(9 * 1024) # 9pt
self.text_view.override_font(font_desc)
# Get text buffer
self.text_buffer = self.text_view.get_buffer()
# Create text tags for different log levels
self._create_text_tags()
scrolled.add(self.text_view)
# Store reference to scrolled window for auto-scrolling
self.scrolled_window = scrolled
return scrolled
def _create_text_tags(self):
"""Create text tags for different log levels."""
# Debug - gray
debug_tag = self.text_buffer.create_tag("DEBUG")
debug_tag.set_property("foreground", "#666666")
# Info - default color
info_tag = self.text_buffer.create_tag("INFO")
# Warning - orange
warning_tag = self.text_buffer.create_tag("WARNING")
warning_tag.set_property("foreground", "#FF8C00")
warning_tag.set_property("weight", 600)
# Error - red
error_tag = self.text_buffer.create_tag("ERROR")
error_tag.set_property("foreground", "#DC143C")
error_tag.set_property("weight", 700)
# Critical - red background
critical_tag = self.text_buffer.create_tag("CRITICAL")
critical_tag.set_property("foreground", "#FFFFFF")
critical_tag.set_property("background", "#DC143C")
critical_tag.set_property("weight", 700)
def _create_status_bar(self):
"""Create the status bar."""
status_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
self.status_label = Gtk.Label()
self.status_label.set_halign(Gtk.Align.START)
status_box.pack_start(self.status_label, True, True, 0)
# Update status
self._update_status()
return status_box
def _update_status(self):
"""Update the status bar with current log statistics."""
stats = self.logging_manager.get_log_stats()
if stats['total'] == 0:
if stats["total"] == 0:
status_text = "No log records"
else:
level_counts = []
for level in ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']:
count = stats['by_level'].get(level, 0)
for level in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]:
count = stats["by_level"].get(level, 0)
if count > 0:
level_counts.append(f"{level}: {count}")
status_text = f"Total: {stats['total']} | " + " | ".join(level_counts)
self.status_label.set_text(status_text)
def _on_filter_changed(self, widget):
"""Handle filter changes."""
# Get current filter values
level_id = self.level_combo.get_active_id()
self.filter_level = None if level_id == "ALL" else level_id
module_text = self.module_entry.get_text().strip()
self.filter_module = module_text if module_text else None
# Refresh logs with new filters
self._refresh_logs()
def _on_auto_scroll_toggled(self, widget):
"""Handle auto-scroll toggle."""
self.auto_scroll = widget.get_active()
def _on_refresh_clicked(self, widget):
"""Handle refresh button click."""
self._refresh_logs()
def _refresh_logs(self):
"""Refresh the log display with current filters."""
# Get filtered logs
records = self.logging_manager.get_logs(
level_filter=self.filter_level,
module_filter=self.filter_module
level_filter=self.filter_level, module_filter=self.filter_module
)
# Clear text buffer
self.text_buffer.set_text("")
# Add log records
for record in records:
self._append_log_record(record)
# Update status
self._update_status()
# Auto-scroll to bottom
if self.auto_scroll:
self._scroll_to_bottom()
def _append_log_record(self, record: LogRecord):
"""
Append a log record to the text view.
Args:
record: The log record to append
"""
# Format the log line
log_line = str(record) + "\n"
# Get end iterator
end_iter = self.text_buffer.get_end_iter()
# Insert text with appropriate tag
tag_name = record.level
if tag_name in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]:
self.text_buffer.insert_with_tags_by_name(end_iter, log_line, tag_name)
else:
self.text_buffer.insert(end_iter, log_line)
def _scroll_to_bottom(self):
"""Scroll the text view to the bottom."""
mark = self.text_buffer.get_insert()
end_iter = self.text_buffer.get_end_iter()
self.text_buffer.place_cursor(end_iter)
self.text_view.scroll_to_mark(mark, 0.0, False, 0.0, 0.0)
def _on_new_log_record(self, record: LogRecord):
"""
Handle new log record from the logging manager.
Args:
record: The new log record
"""
# Check if record matches current filters
if self.filter_level and record.level != self.filter_level:
return
if self.filter_module and self.filter_module.lower() not in record.logger_name.lower():
return
# Add to UI in main thread
GLib.idle_add(self._append_log_record_safe, record)
def _append_log_record_safe(self, record: LogRecord):
"""
Safely append a log record in the main thread.
Args:
record: The log record to append
"""
try:
self._append_log_record(record)
# Auto-scroll if enabled
if self.auto_scroll:
self._scroll_to_bottom()
# Update status
self._update_status()
except Exception as e:
print(f"Error appending log record: {e}")
return False # Remove from idle queue
def _on_response(self, dialog, response_id):
"""Handle dialog responses."""
if response_id == Gtk.ResponseType.APPLY:
@@ -336,49 +335,48 @@ class LoggingDialog(Gtk.Dialog):
self._copy_logs_to_clipboard()
elif response_id == Gtk.ResponseType.CLOSE:
self.destroy()
def _export_logs(self):
"""Export logs to a file."""
# Create file chooser dialog
file_dialog = Gtk.FileChooserDialog(
title="Export Logs",
parent=self,
action=Gtk.FileChooserAction.SAVE
title="Export Logs", parent=self, action=Gtk.FileChooserAction.SAVE
)
file_dialog.add_buttons(
Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
Gtk.STOCK_SAVE, Gtk.ResponseType.OK
Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_SAVE, Gtk.ResponseType.OK
)
# Set default filename
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
default_filename = f"vocalinux_logs_{timestamp}.txt"
file_dialog.set_current_name(default_filename)
# Add file filter
file_filter = Gtk.FileFilter()
file_filter.set_name("Text files")
file_filter.add_pattern("*.txt")
file_dialog.add_filter(file_filter)
response = file_dialog.run()
if response == Gtk.ResponseType.OK:
filepath = file_dialog.get_filename()
success = self.logging_manager.export_logs(
filepath,
level_filter=self.filter_level,
module_filter=self.filter_module
filepath, level_filter=self.filter_level, module_filter=self.filter_module
)
if success:
self._show_message("Export successful", f"Logs exported to:\n{filepath}")
else:
self._show_message("Export failed", "Failed to export logs. Check the logs for details.", Gtk.MessageType.ERROR)
self._show_message(
"Export failed",
"Failed to export logs. Check the logs for details.",
Gtk.MessageType.ERROR,
)
file_dialog.destroy()
def _copy_logs_to_clipboard(self):
"""Copy all visible logs to clipboard."""
try:
@@ -386,43 +384,43 @@ class LoggingDialog(Gtk.Dialog):
start_iter = self.text_buffer.get_start_iter()
end_iter = self.text_buffer.get_end_iter()
text_content = self.text_buffer.get_text(start_iter, end_iter, False)
if not text_content.strip():
self._show_message("No logs to copy", "There are no logs to copy to clipboard.")
return
# Get the clipboard
from gi.repository import Gdk
clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
# Create header for clipboard content
from datetime import datetime
header = f"VocaLinux Logs - Copied at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
header += "=" * 80 + "\n\n"
# Combine header and content
clipboard_content = header + text_content
# Set clipboard content
clipboard.set_text(clipboard_content, -1)
clipboard.store()
# Count lines for user feedback
line_count = len(text_content.strip().split('\n'))
line_count = len(text_content.strip().split("\n"))
self._show_message(
"Logs copied to clipboard",
"Logs copied to clipboard",
f"Successfully copied {line_count} log lines to clipboard.\n\n"
"You can now paste them into any text editor or document."
"You can now paste them into any text editor or document.",
)
except Exception as e:
logger.error(f"Failed to copy logs to clipboard: {e}")
self._show_message(
"Copy failed",
f"Failed to copy logs to clipboard: {e}",
Gtk.MessageType.ERROR
"Copy failed", f"Failed to copy logs to clipboard: {e}", Gtk.MessageType.ERROR
)
def _clear_logs(self):
"""Clear all logs after confirmation."""
# Show confirmation dialog
@@ -431,25 +429,27 @@ class LoggingDialog(Gtk.Dialog):
flags=0,
message_type=Gtk.MessageType.QUESTION,
buttons=Gtk.ButtonsType.YES_NO,
text="Clear all logs?"
text="Clear all logs?",
)
confirm_dialog.format_secondary_text(
"This will permanently remove all log records from memory. "
"This action cannot be undone."
)
response = confirm_dialog.run()
confirm_dialog.destroy()
if response == Gtk.ResponseType.YES:
self.logging_manager.clear_logs()
self._refresh_logs()
self._show_message("Logs cleared", "All log records have been cleared.")
def _show_message(self, title: str, message: str, message_type: Gtk.MessageType = Gtk.MessageType.INFO):
def _show_message(
self, title: str, message: str, message_type: Gtk.MessageType = Gtk.MessageType.INFO
):
"""
Show a message dialog.
Args:
title: Dialog title
message: Message text
@@ -460,13 +460,13 @@ class LoggingDialog(Gtk.Dialog):
flags=0,
message_type=message_type,
buttons=Gtk.ButtonsType.OK,
text=title
text=title,
)
dialog.format_secondary_text(message)
dialog.run()
dialog.destroy()
def _on_destroy(self, widget):
"""Handle dialog destruction."""
# Unregister callback
self.logging_manager.unregister_callback(self._on_new_log_record)
self.logging_manager.unregister_callback(self._on_new_log_record)
+79 -77
View File
@@ -18,24 +18,26 @@ 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 = ""):
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
"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
@@ -45,15 +47,15 @@ class LogRecord:
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
"""
@@ -61,41 +63,39 @@ class LoggingManager:
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'
)
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:]
self.log_records = self.log_records[-self.max_records :]
# Notify callbacks
for callback in self.log_callbacks:
try:
@@ -103,21 +103,21 @@ class LoggingManager:
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
"""
@@ -126,54 +126,58 @@ class LoggingManager:
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]:
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:
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:
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")
@@ -182,73 +186,71 @@ class LoggingManager:
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
}
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
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
"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
def emit(self, record: logging.LogRecord):
"""
Emit a log record to the logging manager.
Args:
record: The logging record to emit
"""
@@ -259,12 +261,12 @@ class LoggingHandler(logging.Handler):
level=record.levelname,
logger_name=record.name,
message=record.getMessage(),
module=getattr(record, 'module', record.name.split('.')[0])
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)
@@ -277,7 +279,7 @@ _logging_manager: Optional[LoggingManager] = None
def get_logging_manager() -> LoggingManager:
"""
Get the global logging manager instance.
Returns:
The global LoggingManager instance
"""
@@ -292,4 +294,4 @@ def initialize_logging():
global _logging_manager
if _logging_manager is None:
_logging_manager = LoggingManager()
logger.info("Global logging manager initialized")
logger.info("Global logging manager initialized")
+268 -153
View File
@@ -120,9 +120,7 @@ def _get_recommended_whisper_model() -> tuple:
if torch.cuda.is_available():
has_cuda = True
cuda_memory_gb = torch.cuda.get_device_properties(
0
).total_memory // (1024**3)
cuda_memory_gb = torch.cuda.get_device_properties(0).total_memory // (1024**3)
except Exception:
pass
@@ -178,32 +176,33 @@ def _get_recommended_vosk_model() -> tuple:
class ModelDownloadDialog(Gtk.Dialog):
"""Dialog showing model download progress."""
"""Dialog showing model download progress with cancel support."""
def __init__(
self, parent, model_name: str, model_size_mb: int, engine: str = "whisper"
):
def __init__(self, parent, model_name: str, model_size_mb: int, engine: str = "whisper"):
super().__init__(
title=f"Downloading {model_name.capitalize()} Model",
transient_for=parent,
flags=Gtk.DialogFlags.MODAL,
)
self.set_default_size(400, 150)
self.set_default_size(450, 180)
self.set_deletable(False) # Prevent closing during download
self.cancelled = False
self.engine = engine
self.model_name = model_name
engine_display = engine.upper() if engine == "vosk" else engine.capitalize()
box = self.get_content_area()
box.set_spacing(15)
box.set_spacing(12)
box.set_margin_start(20)
box.set_margin_end(20)
box.set_margin_top(20)
box.set_margin_bottom(20)
box.set_margin_bottom(15)
# Info label
self.info_label = Gtk.Label(
label=f"Downloading {engine_display} {model_name} model (~{_format_size(model_size_mb)})...\n"
f"This may take a few minutes depending on your connection.",
label=f"Downloading {engine_display} {model_name} model (~{_format_size(model_size_mb)})...",
wrap=True,
justify=Gtk.Justification.CENTER,
)
@@ -212,35 +211,77 @@ class ModelDownloadDialog(Gtk.Dialog):
# Progress bar
self.progress_bar = Gtk.ProgressBar()
self.progress_bar.set_show_text(True)
self.progress_bar.set_text("Preparing download...")
box.pack_start(self.progress_bar, False, False, 0)
self.progress_bar.set_text("Connecting...")
box.pack_start(self.progress_bar, False, False, 5)
# Status label
# Status label (shows speed and ETA)
self.status_label = Gtk.Label(label="")
self.status_label.set_markup("<i>Please wait...</i>")
box.pack_start(self.status_label, False, False, 0)
# Cancel button
self.cancel_button = Gtk.Button(label="Cancel")
self.cancel_button.connect("clicked", self._on_cancel_clicked)
self.cancel_button.set_halign(Gtk.Align.CENTER)
self.cancel_button.set_margin_top(10)
box.pack_start(self.cancel_button, False, False, 0)
self.show_all()
self._pulse_timeout = GLib.timeout_add(100, self._pulse_progress)
# For Whisper, we can't track progress, so pulse
if engine == "whisper":
self._pulse_timeout = GLib.timeout_add(100, self._pulse_progress)
else:
self._pulse_timeout = None
def _pulse_progress(self):
"""Pulse the progress bar while downloading."""
"""Pulse the progress bar while downloading (for Whisper)."""
if self.cancelled:
return False
self.progress_bar.pulse()
return True # Continue pulsing
def _on_cancel_clicked(self, widget):
"""Handle cancel button click."""
self.cancelled = True
self.cancel_button.set_sensitive(False)
self.cancel_button.set_label("Cancelling...")
self.status_label.set_markup("<i>Cancelling download...</i>")
def update_progress(self, fraction: float, speed_mbps: float, status_text: str):
"""Update the progress bar with actual download progress."""
if self.cancelled:
return
# Stop pulsing if we were pulsing
if self._pulse_timeout:
GLib.source_remove(self._pulse_timeout)
self._pulse_timeout = None
self.progress_bar.set_fraction(fraction)
self.progress_bar.set_text(f"{fraction * 100:.0f}%")
self.status_label.set_markup(f"<i>{status_text}</i>")
def set_complete(self, success: bool, message: str = ""):
"""Mark download as complete."""
if hasattr(self, "_pulse_timeout"):
if self._pulse_timeout:
GLib.source_remove(self._pulse_timeout)
self._pulse_timeout = None
# Hide cancel button
self.cancel_button.hide()
if success:
self.progress_bar.set_fraction(1.0)
self.progress_bar.set_text("Complete!")
self.status_label.set_markup(f"<b>✓ Model ready to use</b>")
self.status_label.set_markup("<b>✓ Model ready to use</b>")
else:
self.progress_bar.set_fraction(0)
self.progress_bar.set_text("Failed")
self.status_label.set_markup(f"<span color='red'>✗ {message}</span>")
if "cancelled" in message.lower():
self.status_label.set_markup("<span color='orange'>✗ Download cancelled</span>")
else:
self.status_label.set_markup(f"<span color='red'>✗ {message}</span>")
# Allow closing now
self.set_deletable(True)
@@ -275,15 +316,11 @@ class SettingsDialog(Gtk.Dialog):
self.get_content_area().add(self.grid)
# Engine Selection
self.grid.attach(
Gtk.Label(label="Speech Engine:", halign=Gtk.Align.START), 0, 0, 1, 1
)
self.grid.attach(Gtk.Label(label="Speech Engine:", halign=Gtk.Align.START), 0, 0, 1, 1)
self.engine_combo = Gtk.ComboBoxText()
# Model Size Selection
self.grid.attach(
Gtk.Label(label="Model Size:", halign=Gtk.Align.START), 0, 1, 1, 1
)
self.grid.attach(Gtk.Label(label="Model Size:", halign=Gtk.Align.START), 0, 1, 1, 1)
self.model_combo = Gtk.ComboBoxText()
self.grid.attach(self.model_combo, 1, 1, 1, 1)
@@ -295,55 +332,57 @@ class SettingsDialog(Gtk.Dialog):
)
self.grid.attach(model_legend, 0, 2, 2, 1)
# VOSK Specific Settings Box (initially hidden)
self.vosk_settings_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, spacing=10
)
self.vosk_grid = Gtk.Grid(column_spacing=10, row_spacing=10)
self.vosk_settings_box.pack_start(
# Recognition Settings Box (shared between engines)
self.recognition_settings_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
self.recognition_grid = Gtk.Grid(column_spacing=10, row_spacing=10)
self.recognition_settings_box.pack_start(
Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), False, False, 5
)
self.vosk_settings_box.pack_start(
Gtk.Label(
label="<b>VOSK Settings</b>", use_markup=True, halign=Gtk.Align.START
),
self.recognition_settings_box.pack_start(
Gtk.Label(label="<b>Recognition Settings</b>", use_markup=True, halign=Gtk.Align.START),
False,
False,
5,
)
self.vosk_settings_box.pack_start(self.vosk_grid, False, False, 0)
self.grid.attach(self.vosk_settings_box, 0, 3, 2, 1)
self.recognition_settings_box.pack_start(self.recognition_grid, False, False, 0)
self.grid.attach(self.recognition_settings_box, 0, 3, 2, 1)
# VAD Sensitivity
self.vosk_grid.attach(Gtk.Label(label="VAD Sensitivity (1-5):"), 0, 0, 1, 1)
# VAD Sensitivity (controls how sensitive the mic is to speech vs silence)
self.recognition_grid.attach(
Gtk.Label(label="VAD Sensitivity (1-5):", halign=Gtk.Align.START), 0, 0, 1, 1
)
self.vad_spin = Gtk.SpinButton.new_with_range(1, 5, 1)
self.vad_spin.set_tooltip_text("Higher = more sensitive to quiet speech")
self.vad_spin.connect("value-changed", self._on_vad_changed)
self.vosk_grid.attach(self.vad_spin, 1, 0, 1, 1)
self.recognition_grid.attach(self.vad_spin, 1, 0, 1, 1)
# Silence Timeout
self.vosk_grid.attach(Gtk.Label(label="Silence Timeout (sec):"), 0, 1, 1, 1)
# Silence Timeout (how long to wait before processing)
self.recognition_grid.attach(
Gtk.Label(label="Silence Timeout (sec):", halign=Gtk.Align.START), 0, 1, 1, 1
)
self.silence_spin = Gtk.SpinButton.new_with_range(0.5, 5.0, 0.1)
self.silence_spin.set_digits(1)
self.silence_spin.set_tooltip_text("Wait time after silence before processing speech")
self.silence_spin.connect("value-changed", self._on_silence_changed)
self.vosk_grid.attach(self.silence_spin, 1, 1, 1, 1)
self.recognition_grid.attach(self.silence_spin, 1, 1, 1, 1)
# VOSK Model info label
# VOSK Model info label (shown only for VOSK)
self.vosk_model_info_label = Gtk.Label(
label="",
use_markup=True,
halign=Gtk.Align.START,
wrap=True,
)
self.vosk_grid.attach(self.vosk_model_info_label, 0, 2, 2, 1)
self.recognition_grid.attach(self.vosk_model_info_label, 0, 2, 2, 1)
# VOSK Recommendation label
# VOSK Recommendation label (shown only for VOSK)
self.vosk_recommendation_label = Gtk.Label(
label="",
use_markup=True,
halign=Gtk.Align.START,
wrap=True,
)
self.vosk_grid.attach(self.vosk_recommendation_label, 0, 3, 2, 1)
self.recognition_grid.attach(self.vosk_recommendation_label, 0, 3, 2, 1)
# Whisper Info Box (initially hidden)
self.whisper_info_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5)
@@ -377,23 +416,19 @@ class SettingsDialog(Gtk.Dialog):
halign=Gtk.Align.START,
wrap=True,
)
self.whisper_info_box.pack_start(
self.whisper_recommendation_label, False, False, 5
)
self.whisper_info_box.pack_start(self.whisper_recommendation_label, False, False, 5)
self.grid.attach(self.whisper_info_box, 0, 3, 2, 1)
self.grid.attach(self.whisper_info_box, 0, 4, 2, 1)
# Add model change handler
self.model_combo.connect("changed", self._on_model_changed)
# Test Area
self.grid.attach(
Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), 0, 5, 2, 1
)
self.grid.attach(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), 0, 6, 2, 1)
test_label = Gtk.Label(
label="<b>Test Recognition</b>", use_markup=True, halign=Gtk.Align.START
)
self.grid.attach(test_label, 0, 6, 2, 1)
self.grid.attach(test_label, 0, 7, 2, 1)
scrolled_window = Gtk.ScrolledWindow(hexpand=True, vexpand=True)
scrolled_window.set_min_content_height(100)
@@ -402,11 +437,11 @@ class SettingsDialog(Gtk.Dialog):
)
self.test_buffer = self.test_textview.get_buffer()
scrolled_window.add(self.test_textview)
self.grid.attach(scrolled_window, 0, 7, 2, 1)
self.grid.attach(scrolled_window, 0, 8, 2, 1)
self.test_button = Gtk.Button(label="Start Test (3 seconds)")
self.test_button.connect("clicked", self._on_test_clicked)
self.grid.attach(self.test_button, 0, 8, 2, 1)
self.grid.attach(self.test_button, 0, 9, 2, 1)
# ---- CRITICAL CHANGE ----
# Load settings FIRST before creating UI connections
@@ -502,6 +537,10 @@ class SettingsDialog(Gtk.Dialog):
elif engine == "vosk":
recommended_model, _ = _get_recommended_vosk_model()
# 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
@@ -511,32 +550,54 @@ class SettingsDialog(Gtk.Dialog):
is_downloaded = _is_whisper_model_downloaded(size)
status = "✓" if is_downloaded else "↓"
rec = " ★" if size == recommended_model else ""
display_text = f"{size.capitalize()} ({_format_size(info['size_mb'])}) {status}{rec}"
display_text = (
f"{size.capitalize()} ({_format_size(info['size_mb'])}) {status}{rec}"
)
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)
status = "✓" if is_downloaded else "↓"
rec = " ★" if size == recommended_model else ""
display_text = f"{size.capitalize()} ({_format_size(info['size_mb'])}) {status}{rec}"
display_text = (
f"{size.capitalize()} ({_format_size(info['size_mb'])}) {status}{rec}"
)
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)
# Set the active model from settings
model_to_set = self.current_model_size.capitalize()
logger.info(f"Setting active model to: {model_to_set}")
# Determine which model to select:
# 1. If saved model is downloaded, use it
# 2. Else if any model is downloaded, use the first (smallest) downloaded
# 3. Else use the smallest model (but don't auto-download)
saved_model = self.current_model_size.lower()
if saved_model in downloaded_models:
model_to_set = saved_model.capitalize()
elif downloaded_models:
model_to_set = downloaded_models[0].capitalize()
else:
model_to_set = smallest_model.capitalize() if smallest_model else "Small"
logger.info(
f"Setting active model to: {model_to_set} (saved={saved_model}, downloaded={downloaded_models})"
)
# Try to set by ID
if not self.model_combo.set_active_id(model_to_set):
logger.warning(
f"Could not set model by ID '{model_to_set}', trying by text"
)
logger.warning(f"Could not set model by ID '{model_to_set}', trying by text")
# Find by text as fallback
model = self.model_combo.get_model()
model_found = False
for i, row in enumerate(model):
if row[0].lower() == self.current_model_size.lower():
if row[0].lower() == model_to_set.lower():
self.model_combo.set_active(i)
model_found = True
logger.info(f"Set model by index {i}")
@@ -586,8 +647,69 @@ class SettingsDialog(Gtk.Dialog):
if self._test_active:
return # Don't apply during testing
# Apply settings in background to avoid blocking UI
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):
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
)
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}")
try:
@@ -605,21 +727,29 @@ class SettingsDialog(Gtk.Dialog):
"""Show/hide UI elements specific to the selected engine."""
selected_engine_text = self.engine_combo.get_active_text()
if not selected_engine_text:
self.vosk_settings_box.hide()
self.recognition_settings_box.hide()
self.whisper_info_box.hide()
return
selected_engine = selected_engine_text.lower()
# Recognition settings (VAD, silence timeout) apply to both engines
self.recognition_settings_box.show()
if selected_engine == "vosk":
self.vosk_settings_box.show()
# Show VOSK-specific info labels
self.vosk_model_info_label.show()
self.vosk_recommendation_label.show()
self.whisper_info_box.hide()
self._update_vosk_info()
elif selected_engine == "whisper":
self.vosk_settings_box.hide()
# Hide VOSK-specific info labels, show Whisper info
self.vosk_model_info_label.hide()
self.vosk_recommendation_label.hide()
self.whisper_info_box.show()
self._update_whisper_info()
else:
self.vosk_settings_box.hide()
self.recognition_settings_box.hide()
self.whisper_info_box.hide()
def _update_whisper_info(self):
@@ -643,7 +773,9 @@ class SettingsDialog(Gtk.Dialog):
if is_downloaded:
status_text = "<span foreground='green'>✓ Downloaded and ready</span>"
else:
status_text = f"<span foreground='orange'>↓ Will download ~{_format_size(info['size_mb'])}</span>"
status_text = (
f"<span foreground='orange'>↓ Will download ~{_format_size(info['size_mb'])}</span>"
)
info_text = (
f"<b>{model_name.capitalize()}</b>: {info['desc']}\n"
@@ -684,7 +816,9 @@ class SettingsDialog(Gtk.Dialog):
if is_downloaded:
status_text = "<span foreground='green'>✓ Downloaded and ready</span>"
else:
status_text = f"<span foreground='orange'>↓ Will download ~{_format_size(info['size_mb'])}</span>"
status_text = (
f"<span foreground='orange'>↓ Will download ~{_format_size(info['size_mb'])}</span>"
)
info_text = (
f"<b>{model_name.capitalize()}</b>: {info['desc']}\n"
@@ -721,11 +855,9 @@ class SettingsDialog(Gtk.Dialog):
settings = {
"engine": engine,
"model_size": model_size,
"vad_sensitivity": vad,
"silence_timeout": silence,
}
# Only include VOSK settings if VOSK is selected
if engine == "vosk":
settings["vad_sensitivity"] = vad
settings["silence_timeout"] = silence
return settings
@@ -736,24 +868,20 @@ class SettingsDialog(Gtk.Dialog):
return
# Ensure settings are applied before testing
current_config = self.config_manager.get_settings().get(
"speech_recognition", {}
)
current_config = self.config_manager.get_settings().get("speech_recognition", {})
selected_settings = self.get_selected_settings()
# Check if settings in dialog differ from saved config
# This is a basic check; a more robust diff might be needed
settings_differ = False
if current_config.get("engine") != selected_settings.get(
"engine"
) or current_config.get("model_size") != selected_settings.get("model_size"):
if current_config.get("engine") != selected_settings.get("engine") or current_config.get(
"model_size"
) != selected_settings.get("model_size"):
settings_differ = True
elif selected_settings.get("engine") == "vosk":
if current_config.get("vad_sensitivity") != selected_settings.get(
"vad_sensitivity"
) or current_config.get("silence_timeout") != selected_settings.get(
"silence_timeout"
):
) or current_config.get("silence_timeout") != selected_settings.get("silence_timeout"):
settings_differ = True
# Add checks for other engines if they get specific settings
@@ -792,9 +920,7 @@ class SettingsDialog(Gtk.Dialog):
self.test_buffer.get_start_iter(), self.test_buffer.get_end_iter(), False
)
# Add a space if there's existing text
separator = (
" " if current_text.strip() else ""
) # Check strip() to avoid leading space
separator = " " if current_text.strip() else "" # Check strip() to avoid leading space
self.test_buffer.insert(self.test_buffer.get_end_iter(), separator + text)
# Ensure the text view scrolls to the end
mark = self.test_buffer.get_insert()
@@ -881,76 +1007,65 @@ For now, the engine has been reverted to VOSK."""
settings = self.get_selected_settings()
logger.info(f"Applying settings: {settings}")
# Check if we need to download a Whisper model
if settings.get("engine") == "whisper":
model_name = settings.get("model_size", "base")
if not _is_whisper_model_downloaded(model_name):
# Show download dialog
model_info = WHISPER_MODEL_INFO.get(model_name, {"size_mb": 500})
download_dialog = ModelDownloadDialog(
self, model_name, model_info["size_mb"], engine="whisper"
)
engine = settings.get("engine", "vosk")
model_name = settings.get("model_size", "small")
# Check if we need to download a model
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):
needs_download = True
model_info = VOSK_MODEL_INFO.get(model_name, {"size_mb": 50})
if needs_download:
# Show download dialog
download_dialog = ModelDownloadDialog(
self, model_name, model_info["size_mb"], engine=engine
)
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
cancel_check_id = GLib.timeout_add(100, check_cancelled)
# Run the download in a background thread
def download_and_apply():
try:
# The actual download happens when we reconfigure the engine
self._apply_settings_internal(settings)
GLib.idle_add(download_dialog.set_complete, True, "")
except Exception as e:
error_msg = str(e)
if (
"whisper" in error_msg.lower()
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]
)
finally:
GLib.source_remove(cancel_check_id)
self.speech_engine.set_download_progress_callback(None)
threading.Thread(target=download_and_apply, daemon=True).start()
download_dialog.run()
download_dialog.destroy()
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])
# Refresh the model list to show updated download status
self._populate_model_options()
return True
threading.Thread(target=download_and_apply, daemon=True).start()
download_dialog.run()
download_dialog.destroy()
# Check if we need to download a VOSK model
elif settings.get("engine") == "vosk":
model_name = settings.get("model_size", "small")
if not _is_vosk_model_downloaded(model_name):
# Show download dialog
model_info = VOSK_MODEL_INFO.get(model_name, {"size_mb": 50})
download_dialog = ModelDownloadDialog(
self, model_name, model_info["size_mb"], engine="vosk"
)
# Run the download in a background thread
def download_and_apply():
try:
# The actual download happens when we reconfigure the engine
self._apply_settings_internal(settings)
GLib.idle_add(download_dialog.set_complete, True, "")
except Exception as e:
error_msg = str(e)
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()
# Refresh the model list to show updated download status
self._populate_model_options()
return True
# Refresh the model list to show updated download status
self._populate_model_options()
return True
return self._apply_settings_internal(settings)
+30 -21
View File
@@ -119,9 +119,7 @@ class TrayIndicator:
logger.warning(f"Missing icon files: {validation_results['missing_icons']}")
if validation_results["missing_sounds"]:
logger.warning(
f"Missing sound files: {validation_results['missing_sounds']}"
)
logger.warning(f"Missing sound files: {validation_results['missing_sounds']}")
# Log successful validation
if (
@@ -146,9 +144,7 @@ class TrayIndicator:
for name, path in self.icon_paths.items():
exists = os.path.exists(path)
logger.info(
f"Icon '{name}' ({path}): {'exists' if exists else 'missing'}"
)
logger.info(f"Icon '{name}' ({path}): {'exists' if exists else 'missing'}")
# Create the indicator with absolute path to the default icon
self.indicator = AppIndicator3.Indicator.new_with_path(
@@ -236,9 +232,7 @@ class TrayIndicator:
self._set_menu_item_enabled("Start Voice Typing", False)
self._set_menu_item_enabled("Stop Voice Typing", True)
elif state == RecognitionState.PROCESSING:
self.indicator.set_icon_full(
self.icon_paths["processing"], "Processing speech"
)
self.indicator.set_icon_full(self.icon_paths["processing"], "Processing speech")
self._set_menu_item_enabled("Start Voice Typing", False)
self._set_menu_item_enabled("Stop Voice Typing", True)
elif state == RecognitionState.ERROR:
@@ -302,25 +296,42 @@ class TrayIndicator:
def _on_settings_dialog_response(self, dialog, response):
"""Handle responses from the settings dialog."""
# With auto-apply, we just close the dialog on any response
if (
response == Gtk.ResponseType.CLOSE
or response == Gtk.ResponseType.DELETE_EVENT
):
if response == Gtk.ResponseType.CLOSE or response == Gtk.ResponseType.DELETE_EVENT:
logger.info("Settings dialog closed.")
dialog.destroy()
def _on_about_clicked(self, widget):
"""Handle click on the About menu item."""
from ..version import __copyright__, __description__, __url__, __version__
logger.debug("About clicked")
about_dialog = Gtk.AboutDialog()
about_dialog.set_program_name("Vocalinux")
about_dialog.set_version("0.1.0")
about_dialog.set_copyright("© 2025 | @jatinkrmalik")
about_dialog.set_version(__version__)
about_dialog.set_copyright(__copyright__)
# Authors and credits
about_dialog.set_authors(["Jatin K Malik"])
about_dialog.set_comments("A seamless voice dictation system for Ubuntu")
about_dialog.set_website("https://github.com/jatinkrmalik/vocalinux")
about_dialog.set_website_label("GitHub Repository")
about_dialog.set_documenters(["Jatin K Malik"])
about_dialog.set_artists(["Jatin K Malik"])
# Add credits section with social links
about_dialog.add_credit_section(
"Connect",
[
"GitHub: github.com/jatinkrmalik",
"Twitter/X: @jatinkrmalik",
],
)
# Comments with better formatting
about_dialog.set_comments(
f"{__description__}\n\n" "🌟 Open Source Project\n" "Contributions Welcome!"
)
about_dialog.set_website(__url__)
about_dialog.set_website_label("⭐ Star on GitHub")
about_dialog.set_license_type(Gtk.License.GPL_3_0)
# Set the logo using our custom icon
@@ -328,9 +339,7 @@ class TrayIndicator:
if os.path.exists(logo_path):
try:
pixbuf = GdkPixbuf.Pixbuf.new_from_file(logo_path)
scaled_pixbuf = pixbuf.scale_simple(
150, 150, GdkPixbuf.InterpType.BILINEAR
)
scaled_pixbuf = pixbuf.scale_simple(128, 128, GdkPixbuf.InterpType.BILINEAR)
about_dialog.set_logo(scaled_pixbuf)
except Exception as e:
logger.warning(f"Failed to load or scale logo: {e}")
+1 -1
View File
@@ -4,4 +4,4 @@ Utility modules for Vocalinux.
from .resource_manager import ResourceManager
__all__ = ["ResourceManager"]
__all__ = ["ResourceManager"]
+34 -36
View File
@@ -17,36 +17,36 @@ logger = logging.getLogger(__name__)
class ResourceManager:
"""
Centralized manager for application resources.
This class provides a unified way to locate resources like icons and sounds
regardless of whether the application is running from source, installed via pip,
or installed system-wide.
"""
_instance = None
_resources_dir = None
def __new__(cls):
"""Singleton pattern to ensure only one instance exists."""
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
"""Initialize the resource manager."""
if self._resources_dir is None:
self._resources_dir = self._find_resources_dir()
def _find_resources_dir(self) -> str:
"""
Find the resources directory regardless of how the application is executed.
Returns:
Path to the resources directory
"""
# Get the directory where this module is located
module_dir = Path(__file__).parent.absolute()
# Try several methods to find the resources directory
candidates = [
# For direct repository execution (go up from src/vocalinux/utils to root)
@@ -59,70 +59,72 @@ class ResourceManager:
Path("/usr/local/share/vocalinux/resources"),
Path("/usr/share/vocalinux/resources"),
]
# Log all candidates for debugging
for candidate in candidates:
logger.debug(f"Checking resources candidate: {candidate} (exists: {candidate.exists()})")
logger.debug(
f"Checking resources candidate: {candidate} (exists: {candidate.exists()})"
)
# Return the first candidate that exists
for candidate in candidates:
if candidate.exists():
logger.info(f"Found resources directory: {candidate}")
return str(candidate)
# If no candidate exists, default to the first one (with warning)
default_path = str(candidates[0])
logger.warning(f"Could not find resources directory, defaulting to: {default_path}")
return default_path
@property
def resources_dir(self) -> str:
"""Get the resources directory path."""
return self._resources_dir
@property
def icons_dir(self) -> str:
"""Get the icons directory path."""
return os.path.join(self._resources_dir, "icons", "scalable")
@property
def sounds_dir(self) -> str:
"""Get the sounds directory path."""
return os.path.join(self._resources_dir, "sounds")
def get_icon_path(self, icon_name: str) -> str:
"""
Get the full path to an icon file.
Args:
icon_name: Name of the icon (without extension)
Returns:
Full path to the icon file
"""
return os.path.join(self.icons_dir, f"{icon_name}.svg")
def get_sound_path(self, sound_name: str) -> str:
"""
Get the full path to a sound file.
Args:
sound_name: Name of the sound file (without extension)
Returns:
Full path to the sound file
"""
return os.path.join(self.sounds_dir, f"{sound_name}.wav")
def ensure_directories_exist(self):
"""Ensure that resource directories exist."""
os.makedirs(self.icons_dir, exist_ok=True)
os.makedirs(self.sounds_dir, exist_ok=True)
def validate_resources(self) -> dict:
"""
Validate that all expected resources exist.
Returns:
Dictionary with validation results
"""
@@ -133,30 +135,26 @@ class ResourceManager:
"missing_icons": [],
"missing_sounds": [],
}
# Expected icons
expected_icons = [
"vocalinux",
"vocalinux-microphone",
"vocalinux-microphone-off",
"vocalinux-microphone-process"
"vocalinux-microphone-off",
"vocalinux-microphone-process",
]
for icon in expected_icons:
icon_path = self.get_icon_path(icon)
if not os.path.exists(icon_path):
results["missing_icons"].append(icon)
# Expected sounds
expected_sounds = [
"start_recording",
"stop_recording",
"error"
]
expected_sounds = ["start_recording", "stop_recording", "error"]
for sound in expected_sounds:
sound_path = self.get_sound_path(sound)
if not os.path.exists(sound_path):
results["missing_sounds"].append(sound)
return results
return results
+5 -2
View File
@@ -2,8 +2,11 @@
Version information for Vocalinux.
"""
__version__ = "0.1.0"
__version__ = "0.2.0-alpha"
__version_info__ = (0, 2, 0, "alpha")
__author__ = "Jatin K Malik"
__email__ = "jatinkrmalik@gmail.com"
__license__ = "GPL-3.0"
__copyright__ = "Copyright 2023-2024 Jatin K Malik"
__copyright__ = "Copyright 2023-2025 Jatin K Malik"
__url__ = "https://github.com/jatinkrmalik/vocalinux"
__description__ = "A seamless voice dictation system for Linux"
+8 -16
View File
@@ -34,14 +34,14 @@ def pytest_addoption(parser):
def pytest_collection_modifyitems(config, items):
"""Modify collected test items to skip tray_indicator and audio_feedback tests when running the full suite."""
# Check if we should run tray tests (environment variable or command line option)
run_tray_tests = os.getenv(
"RUN_TRAY_TESTS", "false"
).lower() == "true" or config.getoption("--run-tray-tests", default=False)
run_tray_tests = os.getenv("RUN_TRAY_TESTS", "false").lower() == "true" or config.getoption(
"--run-tray-tests", default=False
)
# Check if we should run audio tests (environment variable or command line option)
run_audio_tests = os.getenv(
"RUN_AUDIO_TESTS", "false"
).lower() == "true" or config.getoption("--run-audio-tests", default=False)
run_audio_tests = os.getenv("RUN_AUDIO_TESTS", "false").lower() == "true" or config.getoption(
"--run-audio-tests", default=False
)
# Get the list of file names being tested
test_files = set()
@@ -49,11 +49,7 @@ def pytest_collection_modifyitems(config, items):
test_files.add(item.fspath.basename)
# If we're running more than just the tray_indicator tests and not explicitly enabling them, skip them
if (
len(test_files) > 1
and "test_tray_indicator.py" in test_files
and not run_tray_tests
):
if len(test_files) > 1 and "test_tray_indicator.py" in test_files and not run_tray_tests:
skip_tray = pytest.mark.skip(
reason="Skipping tray_indicator tests in full suite to prevent hanging (use --run-tray-tests or RUN_TRAY_TESTS=true to enable)"
)
@@ -62,11 +58,7 @@ def pytest_collection_modifyitems(config, items):
item.add_marker(skip_tray)
# If we're running more than just the audio_feedback tests and not explicitly enabling them, skip them
if (
len(test_files) > 1
and "test_audio_feedback.py" in test_files
and not run_audio_tests
):
if len(test_files) > 1 and "test_audio_feedback.py" in test_files and not run_audio_tests:
skip_audio = pytest.mark.skip(
reason="Skipping audio_feedback tests in full suite to prevent CI failures (use --run-audio-tests or RUN_AUDIO_TESTS=true to enable)"
)
+2 -2
View File
@@ -46,9 +46,9 @@ class TestAudioFeedback(unittest.TestCase):
"""Test that resource paths are correctly set up."""
# Import the resource manager to test paths
from vocalinux.utils.resource_manager import ResourceManager
resource_manager = ResourceManager()
# Verify that resource paths are correctly set and accessible
self.assertTrue(
resource_manager.resources_dir.endswith("resources"),
+1 -3
View File
@@ -214,9 +214,7 @@ class TestCommandProcessor(unittest.TestCase):
# Test non-matches
self.assertFalse(self.processor.text_cmd_regex.search("newline")) # no space
self.assertFalse(
self.processor.action_cmd_regex.search("deletion")
) # not a command
self.assertFalse(self.processor.action_cmd_regex.search("deletion")) # not a command
def test_compile_patterns_method(self):
"""Test the _compile_patterns method directly."""
+4 -12
View File
@@ -89,19 +89,13 @@ class TestConfigManager(unittest.TestCase):
config_manager = ConfigManager()
# Verify it merged with defaults correctly
self.assertEqual(
config_manager.config["speech_recognition"]["engine"], "whisper"
)
self.assertEqual(
config_manager.config["speech_recognition"]["model_size"], "large"
)
self.assertEqual(config_manager.config["speech_recognition"]["engine"], "whisper")
self.assertEqual(config_manager.config["speech_recognition"]["model_size"], "large")
self.assertEqual(
config_manager.config["speech_recognition"]["vad_sensitivity"], 3
) # From defaults
self.assertEqual(config_manager.config["ui"]["start_minimized"], True)
self.assertEqual(
config_manager.config["ui"]["show_notifications"], True
) # From defaults
self.assertEqual(config_manager.config["ui"]["show_notifications"], True) # From defaults
def test_load_config_file_error(self):
"""Test handling of errors when loading config file."""
@@ -161,9 +155,7 @@ class TestConfigManager(unittest.TestCase):
config_manager = ConfigManager()
result = config_manager.set("speech_recognition", "engine", "whisper")
self.assertTrue(result)
self.assertEqual(
config_manager.config["speech_recognition"]["engine"], "whisper"
)
self.assertEqual(config_manager.config["speech_recognition"]["engine"], "whisper")
def test_set_new_section(self):
"""Test setting a value in a new section."""
+4 -12
View File
@@ -16,9 +16,7 @@ class TestKeyboardShortcuts(unittest.TestCase):
def setUp(self):
"""Set up for tests."""
# Set up more complete mocks for the keyboard library
self.kb_patch = patch(
"vocalinux.ui.keyboard_shortcuts.KEYBOARD_AVAILABLE", True
)
self.kb_patch = patch("vocalinux.ui.keyboard_shortcuts.KEYBOARD_AVAILABLE", True)
self.kb_patch.start()
# Create proper Key enum and KeyCode class
@@ -167,9 +165,7 @@ class TestKeyboardShortcuts(unittest.TestCase):
self.ksm.register_toggle_callback(callback)
# Set up initial state
self.ksm.last_ctrl_press_time = (
time.time() - 0.2
) # Recent press (within threshold)
self.ksm.last_ctrl_press_time = time.time() - 0.2 # Recent press (within threshold)
self.ksm.last_trigger_time = 0 # No recent triggers
# Simulate second Ctrl press (should trigger callback)
@@ -219,12 +215,8 @@ class TestKeyboardShortcuts(unittest.TestCase):
self.ksm.register_toggle_callback(callback)
# Set up initial state (recent trigger)
self.ksm.last_ctrl_press_time = (
time.time() - 0.2
) # Recent press (within threshold)
self.ksm.last_trigger_time = (
time.time() - 0.2
) # Recent trigger, within debounce
self.ksm.last_ctrl_press_time = time.time() - 0.2 # Recent press (within threshold)
self.ksm.last_trigger_time = time.time() - 0.2 # Recent trigger, within debounce
# Simulate second Ctrl press (should NOT trigger due to debounce)
on_press(self.mock_keyboard.Key.ctrl)
+15 -2
View File
@@ -50,13 +50,26 @@ class TestMainModule(unittest.TestCase):
@patch("vocalinux.text_injection.text_injector.TextInjector")
@patch("vocalinux.ui.tray_indicator.TrayIndicator")
@patch("vocalinux.main.logging")
@patch("vocalinux.ui.config_manager.ConfigManager")
def test_main_initializes_components(
self, mock_logging, mock_tray, mock_text, mock_speech, mock_action_handler, mock_check_deps
self,
mock_config_manager,
mock_logging,
mock_tray,
mock_text,
mock_speech,
mock_action_handler,
mock_check_deps,
):
"""Test that main initializes all the required components."""
# Mock dependency check to return True
mock_check_deps.return_value = True
# Mock ConfigManager to return empty settings (use command-line defaults)
mock_config_instance = MagicMock()
mock_config_instance.get_settings.return_value = {"speech_recognition": {}}
mock_config_manager.return_value = mock_config_instance
# Mock objects
mock_speech_instance = MagicMock()
mock_text_instance = MagicMock()
+1 -3
View File
@@ -59,9 +59,7 @@ class TestSpeechRecognition(unittest.TestCase):
self.mockMakeDirs = patch("os.makedirs")
self.mockThread = patch("threading.Thread")
self.mockPath = patch.object(SpeechRecognitionManager, "_get_vosk_model_path")
self.mockDownload = patch.object(
SpeechRecognitionManager, "_download_vosk_model"
)
self.mockDownload = patch.object(SpeechRecognitionManager, "_download_vosk_model")
self.mockCmdProcessor = patch.object(CommandProcessor, "process_text")
# Start all patches
+23 -18
View File
@@ -81,9 +81,7 @@ class TestSettingsDialog(unittest.TestCase):
self.test_textview.get_buffer.return_value = buffer_mock
# Mock SettingsDialog to avoid actually creating GTK objects
with patch(
"vocalinux.ui.settings_dialog.SettingsDialog.__init__", return_value=None
):
with patch("vocalinux.ui.settings_dialog.SettingsDialog.__init__", return_value=None):
self.dialog = SettingsDialog(
parent=None,
config_manager=mock_config_manager,
@@ -117,6 +115,10 @@ class TestSettingsDialog(unittest.TestCase):
self.dialog._test_text_callback = Mock()
self.dialog._stop_test_after_delay = Mock()
self.dialog.destroy = Mock()
# Add missing attributes for apply_settings
self.dialog.current_model_size = "small"
self.dialog.current_engine = "vosk"
self.dialog._populate_model_options = Mock()
def test_apply_settings_success(self):
"""Test the apply_settings method calls config and engine methods."""
@@ -134,14 +136,15 @@ class TestSettingsDialog(unittest.TestCase):
# Mock the Gtk module for this test
with patch("vocalinux.ui.settings_dialog.Gtk") as mock_gtk, patch(
"vocalinux.ui.settings_dialog.GLib"
) as mock_glib, patch(
"vocalinux.ui.settings_dialog.threading"
) as mock_threading, patch(
) as mock_glib, patch("vocalinux.ui.settings_dialog.threading") as mock_threading, patch(
"vocalinux.ui.settings_dialog.time"
) as mock_time, patch(
"vocalinux.ui.settings_dialog.logging"
) as mock_logging:
) as mock_logging, patch(
"vocalinux.ui.settings_dialog._is_vosk_model_downloaded", return_value=True
) as mock_vosk_check, patch(
"vocalinux.ui.settings_dialog._is_whisper_model_downloaded", return_value=True
) as mock_whisper_check:
# Call the method under test
result = self.dialog.apply_settings()
@@ -164,14 +167,15 @@ class TestSettingsDialog(unittest.TestCase):
# Mock the Gtk module for this test
with patch("vocalinux.ui.settings_dialog.Gtk") as mock_gtk, patch(
"vocalinux.ui.settings_dialog.GLib"
) as mock_glib, patch(
"vocalinux.ui.settings_dialog.threading"
) as mock_threading, patch(
) as mock_glib, patch("vocalinux.ui.settings_dialog.threading") as mock_threading, patch(
"vocalinux.ui.settings_dialog.time"
) as mock_time, patch(
"vocalinux.ui.settings_dialog.logging"
) as mock_logging:
) as mock_logging, patch(
"vocalinux.ui.settings_dialog._is_vosk_model_downloaded", return_value=True
) as mock_vosk_check, patch(
"vocalinux.ui.settings_dialog._is_whisper_model_downloaded", return_value=True
) as mock_whisper_check:
# Call the method under test
result = self.dialog.apply_settings()
@@ -190,14 +194,15 @@ class TestSettingsDialog(unittest.TestCase):
# Mock the Gtk module for this test
with patch("vocalinux.ui.settings_dialog.Gtk") as mock_gtk, patch(
"vocalinux.ui.settings_dialog.GLib"
) as mock_glib, patch(
"vocalinux.ui.settings_dialog.threading"
) as mock_threading, patch(
) as mock_glib, patch("vocalinux.ui.settings_dialog.threading") as mock_threading, patch(
"vocalinux.ui.settings_dialog.time"
) as mock_time, patch(
"vocalinux.ui.settings_dialog.logging"
) as mock_logging:
) as mock_logging, patch(
"vocalinux.ui.settings_dialog._is_vosk_model_downloaded", return_value=True
) as mock_vosk_check, patch(
"vocalinux.ui.settings_dialog._is_whisper_model_downloaded", return_value=True
) as mock_whisper_check:
# Mock the message dialog
mock_dialog = MagicMock()
mock_gtk.MessageDialog.return_value = mock_dialog
+28 -20
View File
@@ -33,10 +33,14 @@ class MockKaldiRecognizer:
return '{"partial": "test"}'
# Create a proper mock vosk module that uses our custom classes
class MockVoskModule:
Model = MockModel
KaldiRecognizer = MockKaldiRecognizer
# Path modules before importing the recognition_manager
sys.modules["vosk"] = MagicMock()
sys.modules["vosk"].Model = MockModel
sys.modules["vosk"].KaldiRecognizer = MockKaldiRecognizer
sys.modules["vosk"] = MockVoskModule
# Mock other required modules
sys.modules["pyaudio"] = MagicMock()
@@ -113,9 +117,7 @@ class TestSpeechRecognition(unittest.TestCase):
self.mock_play_error = self.patcher_play_error.start()
# Patch the download method for vosk models
self.patcher_download = patch.object(
SpeechRecognitionManager, "_download_vosk_model"
)
self.patcher_download = patch.object(SpeechRecognitionManager, "_download_vosk_model")
self.mock_download = self.patcher_download.start()
# Patch os.unlink to avoid file removal errors
@@ -164,6 +166,12 @@ class TestSpeechRecognition(unittest.TestCase):
"""Test registering and using callbacks."""
manager = SpeechRecognitionManager(engine="vosk")
# Replace recognizer with a mock that returns proper JSON
mock_recognizer = MagicMock()
mock_recognizer.FinalResult.return_value = MOCK_VOSK_RESULT
mock_recognizer.AcceptWaveform.return_value = True
manager.recognizer = mock_recognizer
# Create mock callbacks
text_callback = MagicMock()
state_callback = MagicMock()
@@ -205,9 +213,7 @@ class TestSpeechRecognition(unittest.TestCase):
def test_vosk_model_path(self):
"""Test getting the VOSK model path based on size."""
with patch.object(
SpeechRecognitionManager, "_get_vosk_model_path"
) as mock_get_path:
with patch.object(SpeechRecognitionManager, "_get_vosk_model_path") as mock_get_path:
# Test small model size
mock_get_path.return_value = "/path/to/vosk-model-small-en-us-0.15"
manager_small = SpeechRecognitionManager(engine="vosk", model_size="small")
@@ -217,9 +223,7 @@ class TestSpeechRecognition(unittest.TestCase):
# Test medium model size
mock_get_path.return_value = "/path/to/vosk-model-en-us-0.22"
manager_medium = SpeechRecognitionManager(
engine="vosk", model_size="medium"
)
manager_medium = SpeechRecognitionManager(engine="vosk", model_size="medium")
# Verify the medium model path is constructed correctly
mock_get_path.assert_called_with()
@@ -235,8 +239,10 @@ class TestSpeechRecognition(unittest.TestCase):
"""Test downloading VOSK model."""
# Make os.path.exists return False to trigger download
with patch("os.path.exists", return_value=False):
# Instantiate manager which should trigger download
manager = SpeechRecognitionManager(engine="vosk", model_size="small")
# Instantiate manager with defer_download=False to trigger download
manager = SpeechRecognitionManager(
engine="vosk", model_size="small", defer_download=False
)
# Verify download was attempted
self.mock_download.assert_called_once()
@@ -316,6 +322,12 @@ class TestSpeechRecognition(unittest.TestCase):
# Setup manager
manager = SpeechRecognitionManager(engine="vosk")
# Replace recognizer with a mock that returns proper JSON
mock_recognizer = MagicMock()
mock_recognizer.FinalResult.return_value = MOCK_VOSK_RESULT
mock_recognizer.AcceptWaveform.return_value = True
manager.recognizer = mock_recognizer
# Setup command processor mock
self.mock_cmd.process_text.return_value = ("processed text", ["action1"])
@@ -342,9 +354,7 @@ class TestSpeechRecognition(unittest.TestCase):
"""Test processing the final audio buffer with Whisper."""
# Skip the problematic file operations by creating a mock implementation
# of the _process_final_buffer method
with patch.object(
SpeechRecognitionManager, "_process_final_buffer"
) as mock_process:
with patch.object(SpeechRecognitionManager, "_process_final_buffer") as mock_process:
# Setup manager with whisper engine (this should work now with mocked torch)
manager = SpeechRecognitionManager(engine="whisper")
@@ -353,9 +363,7 @@ class TestSpeechRecognition(unittest.TestCase):
# Access transcription result directly from the model
result = {"text": "whisper transcription"}
# Simulate processing the result through command processor
processed_text, actions = self.mock_cmd.process_text(
"whisper transcription"
)
processed_text, actions = self.mock_cmd.process_text("whisper transcription")
# Call callbacks as the real method would
for callback in manager.text_callbacks:
callback(processed_text)
+9 -27
View File
@@ -34,9 +34,7 @@ class TestTextInjector(unittest.TestCase):
self.mock_sleep = self.patch_sleep.start()
# Setup environment variable patching
self.env_patcher = patch.dict(
"os.environ", {"XDG_SESSION_TYPE": "x11", "DISPLAY": ":0"}
)
self.env_patcher = patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11", "DISPLAY": ":0"})
self.env_patcher.start()
# Set default return values
@@ -62,9 +60,7 @@ class TestTextInjector(unittest.TestCase):
def test_detect_x11_environment(self):
"""Test detection of X11 environment."""
# Force our mock_which to be selective based on command
self.mock_which.side_effect = lambda cmd: (
"/usr/bin/xdotool" if cmd == "xdotool" else None
)
self.mock_which.side_effect = lambda cmd: ("/usr/bin/xdotool" if cmd == "xdotool" else None)
# Explicitly set X11 environment
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11"}):
@@ -72,9 +68,7 @@ class TestTextInjector(unittest.TestCase):
injector = TextInjector()
# Force X11 detection by patching the _detect_environment method
with patch.object(
injector, "_detect_environment", return_value=DesktopEnvironment.X11
):
with patch.object(injector, "_detect_environment", return_value=DesktopEnvironment.X11):
injector.environment = DesktopEnvironment.X11
# Verify environment is X11
@@ -84,9 +78,7 @@ class TestTextInjector(unittest.TestCase):
"""Test detection of Wayland environment."""
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
# Make wtype available for Wayland
self.mock_which.side_effect = lambda cmd: (
"/usr/bin/wtype" if cmd == "wtype" else None
)
self.mock_which.side_effect = lambda cmd: ("/usr/bin/wtype" if cmd == "wtype" else None)
# Mock wtype test call to return success
mock_process = MagicMock()
@@ -102,9 +94,7 @@ class TestTextInjector(unittest.TestCase):
"""Test forcing Wayland mode."""
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11"}):
# Make wtype available
self.mock_which.side_effect = lambda cmd: (
"/usr/bin/wtype" if cmd == "wtype" else None
)
self.mock_which.side_effect = lambda cmd: ("/usr/bin/wtype" if cmd == "wtype" else None)
# Create injector with wayland_mode=True
injector = TextInjector(wayland_mode=True)
@@ -124,9 +114,7 @@ class TestTextInjector(unittest.TestCase):
# Make wtype test fail with compositor error
mock_process = MagicMock()
mock_process.returncode = 1
mock_process.stderr = (
"compositor does not support virtual keyboard protocol"
)
mock_process.stderr = "compositor does not support virtual keyboard protocol"
self.mock_subprocess.return_value = mock_process
# Initialize injector
@@ -172,9 +160,7 @@ class TestTextInjector(unittest.TestCase):
"""Test text injection in Wayland environment using wtype."""
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
# Make wtype available
self.mock_which.side_effect = lambda cmd: (
"/usr/bin/wtype" if cmd == "wtype" else None
)
self.mock_which.side_effect = lambda cmd: ("/usr/bin/wtype" if cmd == "wtype" else None)
# Successful wtype test
mock_process = MagicMock()
@@ -256,9 +242,7 @@ class TestTextInjector(unittest.TestCase):
found_escaped = True
break
self.assertTrue(
found_escaped, "Special characters were not properly escaped"
)
self.assertTrue(found_escaped, "Special characters were not properly escaped")
def test_empty_text_injection(self):
"""Test injecting empty text (should do nothing)."""
@@ -291,9 +275,7 @@ class TestTextInjector(unittest.TestCase):
def test_xdotool_error_handling(self):
"""Test handling of xdotool errors."""
# Setup xdotool to fail
mock_error = subprocess.CalledProcessError(
1, ["xdotool", "type"], stderr="Error"
)
mock_error = subprocess.CalledProcessError(1, ["xdotool", "type"], stderr="Error")
self.mock_subprocess.side_effect = mock_error
injector = TextInjector()
+5 -15
View File
@@ -33,9 +33,7 @@ class TestTrayIndicator(unittest.TestCase):
self.mock_thread_class.return_value.start = fake_start
# Patch the pynput keyboard module
self.keyboard_patcher = patch(
"vocalinux.ui.keyboard_shortcuts.keyboard", create=True
)
self.keyboard_patcher = patch("vocalinux.ui.keyboard_shortcuts.keyboard", create=True)
self.mock_keyboard = self.keyboard_patcher.start()
# Patch keyboard module's KEYBOARD_AVAILABLE constant before importing
@@ -62,16 +60,12 @@ class TestTrayIndicator(unittest.TestCase):
self.mock_ksm = MagicMock()
# Patch keyboard shortcuts manager to return our mock
self.ksm_patcher = patch(
"vocalinux.ui.keyboard_shortcuts.KeyboardShortcutManager"
)
self.ksm_patcher = patch("vocalinux.ui.keyboard_shortcuts.KeyboardShortcutManager")
self.mock_ksm_class = self.ksm_patcher.start()
self.mock_ksm_class.return_value = self.mock_ksm
# Patch the settings dialog BEFORE importing TrayIndicator
self.patcher_settings_dialog = patch(
"vocalinux.ui.tray_indicator.SettingsDialog"
)
self.patcher_settings_dialog = patch("vocalinux.ui.tray_indicator.SettingsDialog")
self.mock_settings_dialog_class = self.patcher_settings_dialog.start()
self.mock_settings_dialog = MagicMock()
self.mock_settings_dialog_class.return_value = self.mock_settings_dialog
@@ -137,9 +131,7 @@ class TestTrayIndicator(unittest.TestCase):
self.keyboard_patcher.stop()
# Ensure that the keyboard shortcut manager is stopped
if hasattr(self, "tray_indicator") and hasattr(
self.tray_indicator, "shortcut_manager"
):
if hasattr(self, "tray_indicator") and hasattr(self.tray_indicator, "shortcut_manager"):
self.tray_indicator.shortcut_manager.stop()
def test_initialization(self):
@@ -260,9 +252,7 @@ class TestTrayIndicator(unittest.TestCase):
def test_run(self):
"""Test run method."""
# Patch signal.signal and Gtk.main
with patch("signal.signal") as mock_signal, patch(
"gi.repository.Gtk.main"
) as mock_main:
with patch("signal.signal") as mock_signal, patch("gi.repository.Gtk.main") as mock_main:
# Configure mock_main to return immediately instead of blocking
mock_main.side_effect = lambda: None
+8 -4
View File
@@ -93,14 +93,18 @@ class TestWhisperSupport:
model_mock = MagicMock()
whisper_mock.load_model.return_value = model_mock
# Patch modules
with patch.dict(sys.modules, {"whisper": whisper_mock, "torch": torch_mock}):
# Patch modules and mock the download method to avoid network/file operations
with patch.dict(sys.modules, {"whisper": whisper_mock, "torch": torch_mock}), patch(
"vocalinux.speech_recognition.recognition_manager.SpeechRecognitionManager._download_whisper_model"
) as mock_download, patch("os.path.exists", return_value=True):
from vocalinux.speech_recognition.recognition_manager import (
SpeechRecognitionManager,
)
# Create manager with Whisper engine
manager = SpeechRecognitionManager(engine="whisper", model_size="base")
# Create manager with Whisper engine and defer_download=False to trigger model loading
manager = SpeechRecognitionManager(
engine="whisper", model_size="base", defer_download=False
)
# Verify Whisper was initialized correctly
assert manager.engine == "whisper"
+4 -3
View File
@@ -2,11 +2,12 @@
Version=1.0
Type=Application
Name=Vocalinux
Comment=Voice dictation system for Linux
Comment=Seamless voice dictation system for Linux
GenericName=Voice Typing
Exec=vocalinux
Icon=vocalinux
Terminal=false
Categories=Utility;Accessibility;
Keywords=voice;typing;dictation;speech;recognition;
Categories=Utility;Accessibility;AudioVideo;
Keywords=voice;typing;dictation;speech;recognition;transcription;accessibility;
StartupNotify=true
X-GNOME-UsesNotifications=true