From 1dc1d44665aa580f46b0ca295dfcc2e5f3472854 Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Sun, 28 Dec 2025 15:29:51 -0800 Subject: [PATCH] =?UTF-8?q?feat:=20Alpha=20Launch=20v0.2.0=20-=20Complete?= =?UTF-8?q?=20UX=20Overhaul=20=F0=9F=9A=80=F0=9F=8E=A4=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: prepare for v0.2.0-alpha release * feat: prepare for v0.2.0-alpha release * chore: remove CHANGELOG.md, use GitHub Releases instead * refactor: use existing About dialog instead of Settings section * style: enhance About dialog UI readability * style: fix formatting with black and isort * feat: show VAD and silence timeout settings for Whisper engine - Renamed 'VOSK Settings' to 'Recognition Settings' - VAD Sensitivity and Silence Timeout now apply to both engines - Added tooltips explaining each setting - VOSK-specific model info only shown when VOSK selected * fix: resolve overlapping UI elements in settings dialog * fix: save VAD and silence timeout settings for both engines Previously these settings were only saved when VOSK was selected. Now they persist correctly for Whisper as well. * fix: show download progress when selecting undownloaded model Previously when auto-applying settings, models would download silently in the background without showing the progress dialog. Now: - Download progress dialog appears for undownloaded models - Model icons update correctly after download completes * feat: add download progress, speed, ETA and cancel button - Show real download progress percentage for VOSK models - Display download speed (MB/s) and estimated time remaining - Add Cancel button to abort downloads - Update status during extraction phase - Whisper downloads still pulse (uses internal downloader) * fix: defer model download until needed, don't block startup - App now starts instantly even if models aren't downloaded - Models are downloaded on-demand when selected in Settings - If user tries to dictate without a model, plays error sound - Added model_ready property to check if engine is usable * fix: improve first-run UX without downloaded models - Settings dialog now defaults to smallest available model, not saved config - Only show saved model if it's actually downloaded - Add desktop notification when trying to dictate without a model - Prevents auto-downloading large models on first launch * feat: add download progress tracking for Whisper models - Implement custom Whisper model download with progress/speed/ETA - Uses OpenAI's CDN URLs directly instead of whisper.load_model() - Progress callback works the same as VOSK downloads - Supports cancellation during download * fix: enable download progress callback for Whisper models Progress callback was only being set for VOSK downloads, not Whisper. Now both engines show real-time progress, speed, and ETA. * fix: update tests for defer_download and config manager mocking - test_main: Mock ConfigManager to prevent saved config from overriding test values - test_settings_dialog: Mock ModelDownloadDialog to prevent GTK init errors - test_speech_recognition: Use proper MockVoskModule class instead of MagicMock, pass defer_download=False for download test - test_whisper_support: Pass defer_download=False to trigger model loading in test * fix: resolve test failures for CI - test_main: Fix ConfigManager patch path to use source module - test_settings_dialog: Mock _is_vosk_model_downloaded and _is_whisper_model_downloaded to skip download flow, add missing dialog attributes - test_speech_recognition: Replace recognizer with properly configured mock that returns valid JSON, fixing test isolation issues when running all tests together * fix: mock _download_whisper_model in whisper test to avoid requests exception handling issue * fix: add gir1.2-girepository-2.0 dep for PyGObject on Ubuntu 24.04 - Add missing girepository-2.0 package for newer Ubuntu runners - Use --no-build-isolation for PyGObject to use system packages * fix: skip PyGObject pip install, use system python3-gi package - Use --no-deps for editable install to skip PyGObject - Install other deps explicitly without PyGObject - Add python3-gi-cairo for complete GTK support - Removes problematic girepository-2.0 build requirement * fix: install PyGObject via pip with proper build deps - Remove python3-gi/python3-gi-cairo (doesn't work with setup-python) - Install PyGObject from pip after installing build dependencies - Simplify venv setup without --system-site-packages hack * Fix GObject * fix: set GI_TYPELIB_PATH for pip-installed PyGObject to find AppIndicator3 --- .github/ISSUE_TEMPLATE/bug_report.md | 42 ++ .github/ISSUE_TEMPLATE/config.yml | 8 + .github/ISSUE_TEMPLATE/feature_request.md | 31 ++ .github/PULL_REQUEST_TEMPLATE.md | 33 ++ .github/workflows/release.yml | 84 +++- .github/workflows/unified-pipeline.yml | 228 +++------- .pre-commit-config.yaml | 26 +- CONTRIBUTING.md | 359 ++++++++------- Makefile | 105 +++++ README.md | 215 +++++---- SECURITY.md | 83 ++++ docs/INSTALL.md | 323 +++++++++----- pyproject.toml | 153 +++++++ setup.py | 132 +++--- src/vocalinux/common_types.py | 4 +- src/vocalinux/main.py | 4 +- .../speech_recognition/command_processor.py | 20 +- .../speech_recognition/recognition_manager.py | 342 +++++++++++--- src/vocalinux/text_injection/text_injector.py | 70 ++- src/vocalinux/ui/action_handler.py | 48 +- src/vocalinux/ui/config_manager.py | 6 +- src/vocalinux/ui/keyboard_shortcuts.py | 17 +- src/vocalinux/ui/logging_dialog.py | 232 +++++----- src/vocalinux/ui/logging_manager.py | 156 +++---- src/vocalinux/ui/settings_dialog.py | 421 +++++++++++------- src/vocalinux/ui/tray_indicator.py | 51 ++- src/vocalinux/utils/__init__.py | 2 +- src/vocalinux/utils/resource_manager.py | 70 ++- src/vocalinux/version.py | 7 +- tests/conftest.py | 24 +- tests/test_audio_feedback.py | 4 +- tests/test_command_processor.py | 4 +- tests/test_config_manager.py | 16 +- tests/test_keyboard_shortcuts.py | 16 +- tests/test_main.py | 17 +- tests/test_recognition_manager.py | 4 +- tests/test_settings_dialog.py | 41 +- tests/test_speech_recognition.py | 48 +- tests/test_text_injector.py | 36 +- tests/test_tray_indicator.py | 20 +- tests/test_whisper_support.py | 12 +- vocalinux.desktop | 7 +- 42 files changed, 2222 insertions(+), 1299 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 Makefile create mode 100644 SECURITY.md create mode 100644 pyproject.toml 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 -[![Vocalinux CI](https://github.com/jatinkrmalik/vocalinux/workflows/Vocalinux%20CI/badge.svg)](https://github.com/jatinkrmalik/vocalinux/actions?query=workflow%3A%22Vocalinux+CI%22) +[![Vocalinux CI](https://github.com/jatinkrmalik/vocalinux/workflows/Vocalinux%20CI/badge.svg)](https://github.com/jatinkrmalik/vocalinux/actions) [![codecov](https://codecov.io/gh/jatinkrmalik/vocalinux/branch/main/graph/badge.svg)](https://codecov.io/gh/jatinkrmalik/vocalinux) -[![Status: Early Development](https://img.shields.io/badge/Status-Early%20Development-orange)](https://github.com/jatinkrmalik/vocalinux) +[![Status: Alpha](https://img.shields.io/badge/Status-Alpha-orange)](https://github.com/jatinkrmalik/vocalinux) [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) [![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/) -[![Contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg)](https://github.com/jatinkrmalik/vocalinux/issues) +[![Contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg)](CONTRIBUTING.md) [![GitHub issues](https://img.shields.io/github/issues/jatinkrmalik/vocalinux)](https://github.com/jatinkrmalik/vocalinux/issues) [![GitHub release](https://img.shields.io/github/v/release/jatinkrmalik/vocalinux?include_prereleases)](https://github.com/jatinkrmalik/vocalinux/releases) ![Vocalinux Users](https://github.com/user-attachments/assets/e3d8dd16-3d4f-408c-b899-93d85e98b107) -A seamless voice dictation system for Linux, comparable to the built-in solutions on macOS and Windows. +**A seamless voice dictation system for Linux**, comparable to the built-in solutions on macOS and Windows. -## Overview +> πŸŽ‰ **Alpha Release!** We're excited to share Vocalinux with the community. Try it out and [let us know what you think](https://github.com/jatinkrmalik/vocalinux/issues)! -Vocalinux provides a user-friendly speech-to-text solution for Linux users with: +--- -- Activation via double-tap Ctrl keyboard shortcut -- Real-time transcription with minimal latency -- Universal compatibility across applications -- Offline operation for privacy and reliability -- Visual indicators for microphone status -- Audio feedback for recognition status +## ✨ Features -## Quick Start +- 🎀 **Double-tap Ctrl** to start/stop voice dictation +- ⚑ **Real-time transcription** with minimal latency +- οΏ½οΏ½ **Universal compatibility** across all Linux applications +- πŸ”’ **Offline operation** for privacy and reliability (with VOSK) +- πŸ€– **Optional Whisper AI** support for enhanced accuracy +- 🎨 **System tray integration** with visual status indicators +- πŸ”Š **Audio feedback** for recording status +- βš™οΈ **Graphical settings** dialog for easy configuration -### Prerequisites +## πŸš€ Quick Install -- Ubuntu 22.04 or newer (may work on other Linux distributions) -- Python 3.8 or newer -- X11 or Wayland desktop environment +### One-liner Installation (Recommended) -### Installation +\`\`\`bash +git clone https://github.com/jatinkrmalik/vocalinux.git && cd vocalinux && ./install.sh +\`\`\` -```bash +### Or step by step: + +\`\`\`bash # Clone the repository git clone https://github.com/jatinkrmalik/vocalinux.git cd vocalinux -# Run the installer script +# Run the installer ./install.sh -``` +\`\`\` -The installer will set up everything you need, including system dependencies and a Python virtual environment. +The installer handles everything: system dependencies, Python environment, speech models, and desktop integration. -For detailed installation instructions and options, see [docs/INSTALL.md](docs/INSTALL.md). +### After Installation -### Running Vocalinux - -After installation: - -```bash +\`\`\`bash # Activate the virtual environment source activate-vocalinux.sh # Run Vocalinux vocalinux -``` +\`\`\` -## Usage +Or launch it from your application menu! + +## πŸ“‹ Requirements + +- **OS**: Ubuntu 22.04+ (other Linux distros may work) +- **Python**: 3.8 or newer +- **Display**: X11 or Wayland +- **Hardware**: Microphone for voice input + +## πŸŽ™οΈ Usage ### Voice Dictation -1. Double-tap the Ctrl key to start recording +1. **Double-tap Ctrl** to start recording 2. Speak clearly into your microphone -3. Double-tap Ctrl again or pause speaking to stop recording +3. **Double-tap Ctrl** again (or pause speaking) to stop ### Voice Commands | Command | Action | |---------|--------| | "new line" | Inserts a line break | -| "period" | Types a period (.) | +| "period" / "full stop" | Types a period (.) | | "comma" | Types a comma (,) | | "question mark" | Types a question mark (?) | +| "exclamation mark" | Types an exclamation mark (!) | | "delete that" | Deletes the last sentence | | "capitalize" | Capitalizes the next word | -For a complete user guide, see [docs/USER_GUIDE.md](docs/USER_GUIDE.md). +### Command Line Options -## Configuration +\`\`\`bash +vocalinux --help # Show all options +vocalinux --debug # Enable debug logging +vocalinux --engine whisper # Use Whisper AI engine +vocalinux --model medium # Use medium-sized model +vocalinux --wayland # Force Wayland mode +\`\`\` -Configuration is stored in `~/.config/vocalinux/config.json`. You can customize: +## βš™οΈ Configuration -- Recognition engine (VOSK or Whisper) -- Model size (small, medium, large) -- Keyboard shortcuts -- Audio feedback -- UI behavior -- And more +Configuration is stored in \`~/.config/vocalinux/config.json\`: -## Features +\`\`\`json +{ + "speech_recognition": { + "engine": "vosk", + "model_size": "small", + "vad_sensitivity": 3, + "silence_timeout": 2.0 + } +} +\`\`\` -### Custom Icons +You can also configure settings through the graphical Settings dialog (right-click the tray icon). -Vocalinux includes custom icons for system trays and application launchers that visually indicate: -- Microphone inactive state -- Microphone active state -- Speech processing state +## πŸ”§ Development Setup -### Custom Sounds +\`\`\`bash +# Clone and install in dev mode +git clone https://github.com/jatinkrmalik/vocalinux.git +cd vocalinux +./install.sh --dev -Audio feedback is provided for: -- Start of recording -- End of recording -- Error conditions +# Activate environment +source activate-vocalinux.sh -You can customize these sounds by replacing the files in `resources/sounds/`. +# Run tests +pytest -## Advanced Usage +# Run from source with debug +python -m vocalinux.main --debug +\`\`\` -```bash -# With debugging enabled -vocalinux --debug +## πŸ“ Project Structure -# With a specific speech recognition engine -vocalinux --engine whisper +\`\`\` +vocalinux/ +β”œβ”€β”€ src/vocalinux/ # Main application code +β”‚ β”œβ”€β”€ speech_recognition/ # Speech recognition engines +β”‚ β”œβ”€β”€ text_injection/ # Text injection (X11/Wayland) +β”‚ β”œβ”€β”€ ui/ # GTK UI components +β”‚ └── utils/ # Utility functions +β”œβ”€β”€ tests/ # Test suite +β”œβ”€β”€ resources/ # Icons and sounds +β”œβ”€β”€ docs/ # Documentation +└── web/ # Website source +\`\`\` -# With a specific model size -vocalinux --model medium - -# Force Wayland compatibility mode -vocalinux --wayland -``` - -## Documentation +## πŸ“– Documentation - [Installation Guide](docs/INSTALL.md) - Detailed installation instructions - [User Guide](docs/USER_GUIDE.md) - Complete user documentation - [Contributing](CONTRIBUTING.md) - Development setup and contribution guidelines -## Roadmap +## πŸ—ΊοΈ Roadmap -Future development plans include: +- [x] ~~Custom icon design~~ βœ… +- [x] ~~Graphical settings dialog~~ βœ… +- [x] ~~Whisper AI support~~ βœ… +- [ ] Multi-language support +- [ ] Application-specific commands +- [ ] Debian/Ubuntu package (.deb) +- [ ] Improved Wayland support +- [ ] Voice command customization -1. ~~Custom icon design~~ βœ… (Implemented in v0.1.0) -2. Graphical settings dialog -3. Advanced voice commands for specific applications -4. Multi-language support -5. Better integration with popular applications -6. Improved model management -7. Customizable keyboard shortcuts via GUI -8. More audio feedback options +## 🀝 Contributing -## Contributing +We welcome contributions! Whether it's bug reports, feature requests, or code contributions, please check out our [Contributing Guide](CONTRIBUTING.md). -Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for development setup instructions and contribution guidelines. +### Quick Links -## License +- πŸ› [Report a Bug](https://github.com/jatinkrmalik/vocalinux/issues/new?template=bug_report.md) +- πŸ’‘ [Request a Feature](https://github.com/jatinkrmalik/vocalinux/issues/new?template=feature_request.md) +- πŸ’¬ [Discussions](https://github.com/jatinkrmalik/vocalinux/discussions) -This project is licensed under the GNU General Public License v3.0 - see the [LICENSE](LICENSE) file for details. +## πŸ‘¨β€πŸ’» Author + +**Jatin K Malik** +- GitHub: [@jatinkrmalik](https://github.com/jatinkrmalik) +- Twitter: [@jatinkrmalik](https://twitter.com/jatinkrmalik) + +## ⭐ Support + +If you find Vocalinux useful, please consider: +- ⭐ Starring this repository +- πŸ› Reporting bugs you encounter +- πŸ“– Improving documentation +- πŸ”€ Contributing code + +## πŸ“œ License + +This project is licensed under the **GNU General Public License v3.0** - see the [LICENSE](LICENSE) file for details. + +--- + +

+ 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