diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..f4b40e0
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -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.
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..115f764
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -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
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000..cb8f9e8
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -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
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..3ce1029
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,33 @@
+## Description
+
+
+
+## Related Issue
+
+
+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)
+
+
+
+## Additional Notes
+
+
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 6b86adf..b7d0ea5 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -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 }}
diff --git a/.github/workflows/unified-pipeline.yml b/.github/workflows/unified-pipeline.yml
index 4c1d495..9e0bc2f 100644
--- a/.github/workflows/unified-pipeline.yml
+++ b/.github/workflows/unified-pipeline.yml
@@ -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
\ No newline at end of file
+ force_orphan: true
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 0759b23..f39c5fd 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -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
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 3afad47..4c969dd 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -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! β€οΈ
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..27cc02e
--- /dev/null
+++ b/Makefile
@@ -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__)"
diff --git a/README.md b/README.md
index 3543fc2..d8fe445 100644
--- a/README.md
+++ b/README.md
@@ -1,153 +1,202 @@
-#
Vocalinux
+#
Vocalinux
-[](https://github.com/jatinkrmalik/vocalinux/actions?query=workflow%3A%22Vocalinux+CI%22)
+[](https://github.com/jatinkrmalik/vocalinux/actions)
[](https://codecov.io/gh/jatinkrmalik/vocalinux)
-[](https://github.com/jatinkrmalik/vocalinux)
+[](https://github.com/jatinkrmalik/vocalinux)
[](https://www.gnu.org/licenses/gpl-3.0)
[](https://www.python.org/downloads/)
-[](https://github.com/jatinkrmalik/vocalinux/issues)
+[](CONTRIBUTING.md)
[](https://github.com/jatinkrmalik/vocalinux/issues)
[](https://github.com/jatinkrmalik/vocalinux/releases)

-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.
+
+---
+
+
+ Made with β€οΈ for the Linux community
+
+ github.com/jatinkrmalik/vocalinux
+
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..ebe1530
--- /dev/null
+++ b/SECURITY.md
@@ -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
diff --git a/docs/INSTALL.md b/docs/INSTALL.md
index fc2d4c3..bda6ec1 100644
--- a/docs/INSTALL.md
+++ b/docs/INSTALL.md
@@ -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)
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..b88df8f
--- /dev/null
+++ b/pyproject.toml
@@ -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"]
diff --git a/setup.py b/setup.py
index 1b7992d..6933a83 100644
--- a/setup.py
+++ b/setup.py
@@ -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,
)
diff --git a/src/vocalinux/common_types.py b/src/vocalinux/common_types.py
index 4318bdd..e875d0e 100644
--- a/src/vocalinux/common_types.py
+++ b/src/vocalinux/common_types.py
@@ -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."""
...
diff --git a/src/vocalinux/main.py b/src/vocalinux/main.py
index 7384999..acf631c 100644
--- a/src/vocalinux/main.py
+++ b/src/vocalinux/main.py
@@ -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()
diff --git a/src/vocalinux/speech_recognition/command_processor.py b/src/vocalinux/speech_recognition/command_processor.py
index edf3b83..6c54f35 100644
--- a/src/vocalinux/speech_recognition/command_processor.py
+++ b/src/vocalinux/speech_recognition/command_processor.py
@@ -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":
diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py
index fcb7753..c14b8f1 100644
--- a/src/vocalinux/speech_recognition/recognition_manager.py
+++ b/src/vocalinux/speech_recognition/recognition_manager.py
@@ -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.")
diff --git a/src/vocalinux/text_injection/text_injector.py b/src/vocalinux/text_injection/text_injector.py
index d385221..b8a3e2d 100644
--- a/src/vocalinux/text_injection/text_injector.py
+++ b/src/vocalinux/text_injection/text_injector.py
@@ -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}")
diff --git a/src/vocalinux/ui/action_handler.py b/src/vocalinux/ui/action_handler.py
index 69489d1..901df62 100644
--- a/src/vocalinux/ui/action_handler.py
+++ b/src/vocalinux/ui/action_handler.py
@@ -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")
\ No newline at end of file
+ return self.text_injector._inject_keyboard_shortcut("ctrl+v")
diff --git a/src/vocalinux/ui/config_manager.py b/src/vocalinux/ui/config_manager.py
index 44a36b2..dcb3987 100644
--- a/src/vocalinux/ui/config_manager.py
+++ b/src/vocalinux/ui/config_manager.py
@@ -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
diff --git a/src/vocalinux/ui/keyboard_shortcuts.py b/src/vocalinux/ui/keyboard_shortcuts.py
index c1c4399..400d075 100644
--- a/src/vocalinux/ui/keyboard_shortcuts.py
+++ b/src/vocalinux/ui/keyboard_shortcuts.py
@@ -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)
diff --git a/src/vocalinux/ui/logging_dialog.py b/src/vocalinux/ui/logging_dialog.py
index 3fed20d..9312aab 100644
--- a/src/vocalinux/ui/logging_dialog.py
+++ b/src/vocalinux/ui/logging_dialog.py
@@ -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)
\ No newline at end of file
+ self.logging_manager.unregister_callback(self._on_new_log_record)
diff --git a/src/vocalinux/ui/logging_manager.py b/src/vocalinux/ui/logging_manager.py
index 61bad91..734d0ea 100644
--- a/src/vocalinux/ui/logging_manager.py
+++ b/src/vocalinux/ui/logging_manager.py
@@ -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")
\ No newline at end of file
+ logger.info("Global logging manager initialized")
diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py
index 5a7c916..cd88d86 100644
--- a/src/vocalinux/ui/settings_dialog.py
+++ b/src/vocalinux/ui/settings_dialog.py
@@ -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("Please wait...")
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("Cancelling download...")
+
+ 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"{status_text}")
+
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"β Model ready to use")
+ self.status_label.set_markup("β Model ready to use")
else:
self.progress_bar.set_fraction(0)
self.progress_bar.set_text("Failed")
- self.status_label.set_markup(f"β {message}")
+ if "cancelled" in message.lower():
+ self.status_label.set_markup("β Download cancelled")
+ else:
+ self.status_label.set_markup(f"β {message}")
# 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="VOSK Settings", use_markup=True, halign=Gtk.Align.START
- ),
+ self.recognition_settings_box.pack_start(
+ Gtk.Label(label="Recognition Settings", 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="Test Recognition", 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 = "β Downloaded and ready"
else:
- status_text = f"β Will download ~{_format_size(info['size_mb'])}"
+ status_text = (
+ f"β Will download ~{_format_size(info['size_mb'])}"
+ )
info_text = (
f"{model_name.capitalize()}: {info['desc']}\n"
@@ -684,7 +816,9 @@ class SettingsDialog(Gtk.Dialog):
if is_downloaded:
status_text = "β Downloaded and ready"
else:
- status_text = f"β Will download ~{_format_size(info['size_mb'])}"
+ status_text = (
+ f"β Will download ~{_format_size(info['size_mb'])}"
+ )
info_text = (
f"{model_name.capitalize()}: {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)
diff --git a/src/vocalinux/ui/tray_indicator.py b/src/vocalinux/ui/tray_indicator.py
index ed9ceef..d0afe7c 100644
--- a/src/vocalinux/ui/tray_indicator.py
+++ b/src/vocalinux/ui/tray_indicator.py
@@ -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}")
diff --git a/src/vocalinux/utils/__init__.py b/src/vocalinux/utils/__init__.py
index 5a18094..b0be24e 100644
--- a/src/vocalinux/utils/__init__.py
+++ b/src/vocalinux/utils/__init__.py
@@ -4,4 +4,4 @@ Utility modules for Vocalinux.
from .resource_manager import ResourceManager
-__all__ = ["ResourceManager"]
\ No newline at end of file
+__all__ = ["ResourceManager"]
diff --git a/src/vocalinux/utils/resource_manager.py b/src/vocalinux/utils/resource_manager.py
index 13d9f8e..289d03a 100644
--- a/src/vocalinux/utils/resource_manager.py
+++ b/src/vocalinux/utils/resource_manager.py
@@ -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
\ No newline at end of file
+
+ return results
diff --git a/src/vocalinux/version.py b/src/vocalinux/version.py
index 22ef249..7e07756 100644
--- a/src/vocalinux/version.py
+++ b/src/vocalinux/version.py
@@ -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"
\ No newline at end of file
+__copyright__ = "Copyright 2023-2025 Jatin K Malik"
+__url__ = "https://github.com/jatinkrmalik/vocalinux"
+__description__ = "A seamless voice dictation system for Linux"
diff --git a/tests/conftest.py b/tests/conftest.py
index f50fd60..1c0619f 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -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)"
)
diff --git a/tests/test_audio_feedback.py b/tests/test_audio_feedback.py
index 6352875..2f93c7d 100644
--- a/tests/test_audio_feedback.py
+++ b/tests/test_audio_feedback.py
@@ -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"),
diff --git a/tests/test_command_processor.py b/tests/test_command_processor.py
index 96b5a34..a3e154a 100644
--- a/tests/test_command_processor.py
+++ b/tests/test_command_processor.py
@@ -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."""
diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py
index ab63c25..98aaa1b 100644
--- a/tests/test_config_manager.py
+++ b/tests/test_config_manager.py
@@ -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."""
diff --git a/tests/test_keyboard_shortcuts.py b/tests/test_keyboard_shortcuts.py
index f5459b6..4b4d441 100644
--- a/tests/test_keyboard_shortcuts.py
+++ b/tests/test_keyboard_shortcuts.py
@@ -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)
diff --git a/tests/test_main.py b/tests/test_main.py
index 3d2cf84..5f13d06 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -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()
diff --git a/tests/test_recognition_manager.py b/tests/test_recognition_manager.py
index 57d1305..22b3544 100644
--- a/tests/test_recognition_manager.py
+++ b/tests/test_recognition_manager.py
@@ -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
diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py
index 15bca2f..6081e96 100644
--- a/tests/test_settings_dialog.py
+++ b/tests/test_settings_dialog.py
@@ -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
diff --git a/tests/test_speech_recognition.py b/tests/test_speech_recognition.py
index 6506969..bf98669 100644
--- a/tests/test_speech_recognition.py
+++ b/tests/test_speech_recognition.py
@@ -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)
diff --git a/tests/test_text_injector.py b/tests/test_text_injector.py
index f442d8c..8f863d4 100644
--- a/tests/test_text_injector.py
+++ b/tests/test_text_injector.py
@@ -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()
diff --git a/tests/test_tray_indicator.py b/tests/test_tray_indicator.py
index 8bea8d8..7e96cfa 100644
--- a/tests/test_tray_indicator.py
+++ b/tests/test_tray_indicator.py
@@ -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
diff --git a/tests/test_whisper_support.py b/tests/test_whisper_support.py
index 6410286..44176d0 100644
--- a/tests/test_whisper_support.py
+++ b/tests/test_whisper_support.py
@@ -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"
diff --git a/vocalinux.desktop b/vocalinux.desktop
index 2a17e4c..2fddf9b 100644
--- a/vocalinux.desktop
+++ b/vocalinux.desktop
@@ -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