feat: Alpha Launch v0.2.0 - Complete UX Overhaul 🚀🎤 (#39)
* feat: prepare for v0.2.0-alpha release * feat: prepare for v0.2.0-alpha release * chore: remove CHANGELOG.md, use GitHub Releases instead * refactor: use existing About dialog instead of Settings section * style: enhance About dialog UI readability * style: fix formatting with black and isort * feat: show VAD and silence timeout settings for Whisper engine - Renamed 'VOSK Settings' to 'Recognition Settings' - VAD Sensitivity and Silence Timeout now apply to both engines - Added tooltips explaining each setting - VOSK-specific model info only shown when VOSK selected * fix: resolve overlapping UI elements in settings dialog * fix: save VAD and silence timeout settings for both engines Previously these settings were only saved when VOSK was selected. Now they persist correctly for Whisper as well. * fix: show download progress when selecting undownloaded model Previously when auto-applying settings, models would download silently in the background without showing the progress dialog. Now: - Download progress dialog appears for undownloaded models - Model icons update correctly after download completes * feat: add download progress, speed, ETA and cancel button - Show real download progress percentage for VOSK models - Display download speed (MB/s) and estimated time remaining - Add Cancel button to abort downloads - Update status during extraction phase - Whisper downloads still pulse (uses internal downloader) * fix: defer model download until needed, don't block startup - App now starts instantly even if models aren't downloaded - Models are downloaded on-demand when selected in Settings - If user tries to dictate without a model, plays error sound - Added model_ready property to check if engine is usable * fix: improve first-run UX without downloaded models - Settings dialog now defaults to smallest available model, not saved config - Only show saved model if it's actually downloaded - Add desktop notification when trying to dictate without a model - Prevents auto-downloading large models on first launch * feat: add download progress tracking for Whisper models - Implement custom Whisper model download with progress/speed/ETA - Uses OpenAI's CDN URLs directly instead of whisper.load_model() - Progress callback works the same as VOSK downloads - Supports cancellation during download * fix: enable download progress callback for Whisper models Progress callback was only being set for VOSK downloads, not Whisper. Now both engines show real-time progress, speed, and ETA. * fix: update tests for defer_download and config manager mocking - test_main: Mock ConfigManager to prevent saved config from overriding test values - test_settings_dialog: Mock ModelDownloadDialog to prevent GTK init errors - test_speech_recognition: Use proper MockVoskModule class instead of MagicMock, pass defer_download=False for download test - test_whisper_support: Pass defer_download=False to trigger model loading in test * fix: resolve test failures for CI - test_main: Fix ConfigManager patch path to use source module - test_settings_dialog: Mock _is_vosk_model_downloaded and _is_whisper_model_downloaded to skip download flow, add missing dialog attributes - test_speech_recognition: Replace recognizer with properly configured mock that returns valid JSON, fixing test isolation issues when running all tests together * fix: mock _download_whisper_model in whisper test to avoid requests exception handling issue * fix: add gir1.2-girepository-2.0 dep for PyGObject on Ubuntu 24.04 - Add missing girepository-2.0 package for newer Ubuntu runners - Use --no-build-isolation for PyGObject to use system packages * fix: skip PyGObject pip install, use system python3-gi package - Use --no-deps for editable install to skip PyGObject - Install other deps explicitly without PyGObject - Add python3-gi-cairo for complete GTK support - Removes problematic girepository-2.0 build requirement * fix: install PyGObject via pip with proper build deps - Remove python3-gi/python3-gi-cairo (doesn't work with setup-python) - Install PyGObject from pip after installing build dependencies - Simplify venv setup without --system-site-packages hack * Fix GObject * fix: set GI_TYPELIB_PATH for pip-installed PyGObject to find AppIndicator3
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,33 @@
|
||||
## Description
|
||||
|
||||
<!-- Describe your changes in detail -->
|
||||
|
||||
## Related Issue
|
||||
|
||||
<!-- Link to the related issue if applicable -->
|
||||
Fixes #
|
||||
|
||||
## Type of Change
|
||||
|
||||
- [ ] 🐛 Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] ✨ New feature (non-breaking change which adds functionality)
|
||||
- [ ] 💥 Breaking change (fix or feature that would cause existing functionality to change)
|
||||
- [ ] 📖 Documentation update
|
||||
- [ ] 🧹 Code refactoring
|
||||
- [ ] ✅ Test update
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] My code follows the code style of this project (black, isort)
|
||||
- [ ] I have updated the documentation accordingly
|
||||
- [ ] I have added tests to cover my changes
|
||||
- [ ] All new and existing tests pass locally
|
||||
- [ ] Pre-commit hooks pass
|
||||
|
||||
## Screenshots (if applicable)
|
||||
|
||||
<!-- Add screenshots to help explain your changes -->
|
||||
|
||||
## Additional Notes
|
||||
|
||||
<!-- Add any additional notes for reviewers -->
|
||||
@@ -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 }}
|
||||
|
||||
@@ -1,23 +1,12 @@
|
||||
name: Vocalinux Unified Pipeline
|
||||
name: Vocalinux CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'docs/**'
|
||||
pull_request:
|
||||
branches: [ main, master ]
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'docs/**'
|
||||
workflow_dispatch:
|
||||
|
||||
# Define path filters for different components
|
||||
env:
|
||||
WEB_FILES_FILTER: 'web/**'
|
||||
PYTHON_FILES_FILTER: 'src/**,tests/**,setup.py'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pages: write
|
||||
@@ -25,106 +14,61 @@ permissions:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
python-lint:
|
||||
name: Python Lint
|
||||
name: 🔍 Lint Python
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# This step checks if Python files were changed
|
||||
- name: Check for Python file changes
|
||||
uses: dorny/paths-filter@v2
|
||||
id: changes
|
||||
with:
|
||||
filters: |
|
||||
python:
|
||||
- '${{ env.PYTHON_FILES_FILTER }}'
|
||||
|
||||
# Skip the rest of the job if no Python files were changed
|
||||
- name: Skip if no Python files changed
|
||||
if: steps.changes.outputs.python != 'true' && github.event_name != 'workflow_dispatch' && !contains(github.event.head_commit.message, '[python]')
|
||||
run: |
|
||||
echo "No Python files were changed. Skipping Python linting."
|
||||
exit 0
|
||||
|
||||
- name: Set up Python
|
||||
if: steps.changes.outputs.python == 'true' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[python]')
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.10'
|
||||
cache: 'pip'
|
||||
cache-dependency-path: 'setup.py'
|
||||
|
||||
- name: Install development dependencies
|
||||
if: steps.changes.outputs.python == 'true' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[python]')
|
||||
- name: Install linting tools
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install flake8 black isort
|
||||
pip install flake8 black isort
|
||||
|
||||
- name: Lint with flake8
|
||||
if: steps.changes.outputs.python == 'true' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[python]')
|
||||
- name: Lint with flake8 (critical errors)
|
||||
run: |
|
||||
# stop the build if there are Python syntax errors or undefined names
|
||||
flake8 src/ tests/ --count --select=E9,F63,F7,F82 --show-source --statistics
|
||||
# exit-zero treats all errors as warnings
|
||||
flake8 src/ tests/ --count --exit-zero --max-complexity=10 --max-line-length=100 --statistics
|
||||
|
||||
- name: Check formatting with black
|
||||
if: steps.changes.outputs.python == 'true' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[python]')
|
||||
run: |
|
||||
black --check src/ tests/
|
||||
black --check --diff src/ tests/
|
||||
|
||||
- name: Check import sorting with isort
|
||||
if: steps.changes.outputs.python == 'true' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[python]')
|
||||
run: |
|
||||
isort --check-only --profile black src/ tests/
|
||||
isort --check-only --diff --profile black src/ tests/
|
||||
|
||||
python-test:
|
||||
name: Python Test
|
||||
name: 🧪 Test Python ${{ matrix.python-version }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: python-lint
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: [3.8]
|
||||
python-version: ['3.8', '3.10', '3.11']
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# This step checks if Python files were changed
|
||||
- name: Check for Python file changes
|
||||
uses: dorny/paths-filter@v2
|
||||
id: changes
|
||||
with:
|
||||
filters: |
|
||||
python:
|
||||
- '${{ env.PYTHON_FILES_FILTER }}'
|
||||
|
||||
# Skip the rest of the job if no Python files were changed
|
||||
- name: Skip if no Python files changed
|
||||
if: steps.changes.outputs.python != 'true' && github.event_name != 'workflow_dispatch' && !contains(github.event.head_commit.message, '[python]')
|
||||
run: |
|
||||
echo "No Python files were changed. Skipping Python tests."
|
||||
exit 0
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
if: steps.changes.outputs.python == 'true' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[python]')
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'pip'
|
||||
cache-dependency-path: 'setup.py'
|
||||
|
||||
- name: Install system dependencies
|
||||
if: steps.changes.outputs.python == 'true' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[python]')
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
xdotool \
|
||||
python3-gi \
|
||||
gir1.2-appindicator3-0.1 \
|
||||
libcairo2-dev \
|
||||
pkg-config \
|
||||
@@ -135,74 +79,55 @@ jobs:
|
||||
gir1.2-gtk-3.0 \
|
||||
portaudio19-dev
|
||||
|
||||
- name: Cache apt packages
|
||||
if: steps.changes.outputs.python == 'true' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[python]')
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: /var/cache/apt/archives
|
||||
key: ${{ runner.os }}-apt-${{ hashFiles('**/apt-packages.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-apt-
|
||||
- name: Install GObject introspection dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
# For Ubuntu 24.04+ (or the latest GitHub Actions runner image)
|
||||
sudo apt-get install -y gir1.2-glib-2.0 libgirepository-2.0-dev pkg-config
|
||||
# Alternatively, for older Ubuntu versions (like 22.04), you might need:
|
||||
# sudo apt-get install -y libgirepository1.0-dev pkg-config
|
||||
|
||||
- name: Set up virtual environment
|
||||
if: steps.changes.outputs.python == 'true' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[python]')
|
||||
run: |
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
python -m pip install --upgrade pip
|
||||
# Install the package in development mode with dev dependencies
|
||||
python -m pip install -e ".[dev]"
|
||||
python -m pip install pytest pytest-cov
|
||||
python -m pip install --upgrade pip setuptools wheel
|
||||
# Install PyGObject from pip (with build deps installed above)
|
||||
pip install PyGObject
|
||||
pip install -e ".[dev]"
|
||||
echo "VIRTUAL_ENV=$VIRTUAL_ENV" >> $GITHUB_ENV
|
||||
echo "PATH=$VIRTUAL_ENV/bin:$PATH" >> $GITHUB_ENV
|
||||
|
||||
- name: Run pytest suite with coverage
|
||||
if: steps.changes.outputs.python == 'true' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[python]')
|
||||
- name: Run tests with coverage
|
||||
run: |
|
||||
source venv/bin/activate
|
||||
# Make sure Python can find the 'src' module
|
||||
PYTHONPATH=$PWD pytest --junitxml=junit.xml --cov=src tests/
|
||||
# Set GI_TYPELIB_PATH so pip-installed PyGObject can find system typelibs (AppIndicator3, etc.)
|
||||
export GI_TYPELIB_PATH=/usr/lib/x86_64-linux-gnu/girepository-1.0:/usr/lib/girepository-1.0
|
||||
PYTHONPATH=$PWD pytest --junitxml=junit-${{ matrix.python-version }}.xml --cov=src --cov-report=xml tests/
|
||||
|
||||
- name: Upload test results to Codecov
|
||||
if: ${{ !cancelled() && (steps.changes.outputs.python == 'true' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[python]')) }}
|
||||
uses: codecov/test-results-action@v1
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-results-${{ matrix.python-version }}
|
||||
path: junit-${{ matrix.python-version }}.xml
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
if: matrix.python-version == '3.10'
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: coverage.xml
|
||||
fail_ci_if_error: false
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: ${{ steps.changes.outputs.python == 'true' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[python]') }}
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
web-validate:
|
||||
name: Web Validation
|
||||
web-build:
|
||||
name: 🌐 Build Website
|
||||
runs-on: ubuntu-latest
|
||||
# Use GitHub's path filtering to determine if this job should run
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[web]') }}
|
||||
if: contains(github.event.head_commit.message, '[web]') || github.event_name == 'workflow_dispatch'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# This step checks if web files were changed
|
||||
- name: Check for web file changes
|
||||
uses: dorny/paths-filter@v2
|
||||
id: changes
|
||||
with:
|
||||
filters: |
|
||||
web:
|
||||
- '${{ env.WEB_FILES_FILTER }}'
|
||||
|
||||
# Skip the rest of the job if no web files were changed
|
||||
- name: Skip if no web files changed
|
||||
if: steps.changes.outputs.web != 'true' && github.event_name != 'workflow_dispatch' && !contains(github.event.head_commit.message, '[web]')
|
||||
run: |
|
||||
echo "No web files were changed. Skipping web validation."
|
||||
exit 0
|
||||
|
||||
# Continue with web validation if files were changed
|
||||
- name: Setup Node.js
|
||||
if: steps.changes.outputs.web == 'true' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[web]')
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
@@ -210,90 +135,47 @@ jobs:
|
||||
cache-dependency-path: './web/package-lock.json'
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.changes.outputs.web == 'true' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[web]')
|
||||
working-directory: ./web
|
||||
run: |
|
||||
if [ -f "package-lock.json" ]; then
|
||||
npm ci
|
||||
else
|
||||
npm install
|
||||
fi
|
||||
run: npm ci || npm install
|
||||
|
||||
- name: Lint
|
||||
if: steps.changes.outputs.web == 'true' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[web]')
|
||||
working-directory: ./web
|
||||
run: npm run lint || echo "Linting failed but continuing with the build"
|
||||
run: npm run lint || true
|
||||
|
||||
- name: Type check
|
||||
if: steps.changes.outputs.web == 'true' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[web]')
|
||||
working-directory: ./web
|
||||
run: npm run test:types || echo "Type checking failed but continuing with the build"
|
||||
|
||||
- name: Format check
|
||||
if: steps.changes.outputs.web == 'true' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[web]')
|
||||
working-directory: ./web
|
||||
run: npm run format:check || echo "Format checking failed but continuing with the build"
|
||||
run: npm run test:types || true
|
||||
|
||||
- name: Build
|
||||
if: steps.changes.outputs.web == 'true' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[web]')
|
||||
working-directory: ./web
|
||||
run: npm run build
|
||||
|
||||
deploy-website:
|
||||
name: Deploy Website
|
||||
name: 🚀 Deploy Website
|
||||
runs-on: ubuntu-latest
|
||||
needs: [python-test, web-validate]
|
||||
# Only deploy on pushes to main branch
|
||||
if: ${{ github.event_name != 'pull_request' && github.ref == 'refs/heads/main' }}
|
||||
needs: [python-test, web-build]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && contains(github.event.head_commit.message, '[web]')
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# This step checks if web files were changed
|
||||
- name: Check for web file changes
|
||||
uses: dorny/paths-filter@v2
|
||||
id: changes
|
||||
with:
|
||||
filters: |
|
||||
web:
|
||||
- '${{ env.WEB_FILES_FILTER }}'
|
||||
|
||||
# Skip the rest of the job if no web files were changed
|
||||
- name: Skip if no web files changed
|
||||
if: steps.changes.outputs.web != 'true' && !contains(github.event.head_commit.message, '[web]')
|
||||
run: |
|
||||
echo "No web files were changed. Skipping website deployment."
|
||||
exit 0
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.changes.outputs.web == 'true' || contains(github.event.head_commit.message, '[web]')
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: './web/package-lock.json'
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.changes.outputs.web == 'true' || contains(github.event.head_commit.message, '[web]')
|
||||
- name: Install and build
|
||||
working-directory: ./web
|
||||
run: |
|
||||
if [ -f "package-lock.json" ]; then
|
||||
npm ci
|
||||
else
|
||||
npm install
|
||||
fi
|
||||
|
||||
- name: Build website
|
||||
if: steps.changes.outputs.web == 'true' || contains(github.event.head_commit.message, '[web]')
|
||||
working-directory: ./web
|
||||
run: npm run deploy
|
||||
npm ci || npm install
|
||||
npm run deploy
|
||||
|
||||
- name: Deploy to gh-pages
|
||||
if: steps.changes.outputs.web == 'true' || contains(github.event.head_commit.message, '[web]')
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
uses: peaceiris/actions-gh-pages@v4
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./web/out
|
||||
publish_branch: gh-pages
|
||||
cname: vocalinux.com
|
||||
force_orphan: true
|
||||
force_orphan: true
|
||||
|
||||
+18
-8
@@ -1,26 +1,36 @@
|
||||
# Pre-commit hooks for Vocalinux
|
||||
# Run: pre-commit run --all-files
|
||||
|
||||
repos:
|
||||
# Code formatting with Black
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 24.1.1
|
||||
hooks:
|
||||
- id: black
|
||||
args: [--check]
|
||||
language_version: python3
|
||||
files: ^(src|tests)/
|
||||
|
||||
# Import sorting with isort (black-compatible)
|
||||
- repo: https://github.com/pycqa/isort
|
||||
rev: 5.13.2
|
||||
hooks:
|
||||
- id: isort
|
||||
args: ["--check-only", "--profile", "black"]
|
||||
args: ["--profile", "black"]
|
||||
files: ^(src|tests)/
|
||||
|
||||
# Linting with flake8 (critical errors only)
|
||||
- repo: https://github.com/pycqa/flake8
|
||||
rev: 7.0.0
|
||||
hooks:
|
||||
- id: flake8
|
||||
name: flake8 (critical)
|
||||
args: ["--count", "--select=E9,F63,F7,F82", "--show-source", "--statistics"]
|
||||
files: ^(src|tests)/
|
||||
- id: flake8
|
||||
name: flake8 (warnings)
|
||||
args: ["--count", "--exit-zero", "--max-complexity=10", "--max-line-length=100", "--statistics"]
|
||||
args: ["--select=E9,F63,F7,F82", "--max-line-length=100"]
|
||||
files: ^(src|tests)/
|
||||
|
||||
# Basic file checks
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.5.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
- id: check-added-large-files
|
||||
|
||||
+201
-158
@@ -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! ❤️
|
||||
|
||||
@@ -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__)"
|
||||
@@ -1,153 +1,202 @@
|
||||
# <img src="https://github.com/user-attachments/assets/56dabe5c-5c65-44d5-a36a-429c9fea0719" width="50" height="50"> Vocalinux
|
||||
# <img src="https://github.com/user-attachments/assets/56dabe5c-5c65-44d5-a36a-429c9fea0719" width="50" height="50"> Vocalinux
|
||||
|
||||
[](https://github.com/jatinkrmalik/vocalinux/actions?query=workflow%3A%22Vocalinux+CI%22)
|
||||
[](https://github.com/jatinkrmalik/vocalinux/actions)
|
||||
[](https://codecov.io/gh/jatinkrmalik/vocalinux)
|
||||
[](https://github.com/jatinkrmalik/vocalinux)
|
||||
[](https://github.com/jatinkrmalik/vocalinux)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://github.com/jatinkrmalik/vocalinux/issues)
|
||||
[](CONTRIBUTING.md)
|
||||
[](https://github.com/jatinkrmalik/vocalinux/issues)
|
||||
[](https://github.com/jatinkrmalik/vocalinux/releases)
|
||||
|
||||

|
||||
|
||||
A seamless voice dictation system for Linux, comparable to the built-in solutions on macOS and Windows.
|
||||
**A seamless voice dictation system for Linux**, comparable to the built-in solutions on macOS and Windows.
|
||||
|
||||
## Overview
|
||||
> 🎉 **Alpha Release!** We're excited to share Vocalinux with the community. Try it out and [let us know what you think](https://github.com/jatinkrmalik/vocalinux/issues)!
|
||||
|
||||
Vocalinux provides a user-friendly speech-to-text solution for Linux users with:
|
||||
---
|
||||
|
||||
- Activation via double-tap Ctrl keyboard shortcut
|
||||
- Real-time transcription with minimal latency
|
||||
- Universal compatibility across applications
|
||||
- Offline operation for privacy and reliability
|
||||
- Visual indicators for microphone status
|
||||
- Audio feedback for recognition status
|
||||
## ✨ Features
|
||||
|
||||
## Quick Start
|
||||
- 🎤 **Double-tap Ctrl** to start/stop voice dictation
|
||||
- ⚡ **Real-time transcription** with minimal latency
|
||||
- �� **Universal compatibility** across all Linux applications
|
||||
- 🔒 **Offline operation** for privacy and reliability (with VOSK)
|
||||
- 🤖 **Optional Whisper AI** support for enhanced accuracy
|
||||
- 🎨 **System tray integration** with visual status indicators
|
||||
- 🔊 **Audio feedback** for recording status
|
||||
- ⚙️ **Graphical settings** dialog for easy configuration
|
||||
|
||||
### Prerequisites
|
||||
## 🚀 Quick Install
|
||||
|
||||
- Ubuntu 22.04 or newer (may work on other Linux distributions)
|
||||
- Python 3.8 or newer
|
||||
- X11 or Wayland desktop environment
|
||||
### One-liner Installation (Recommended)
|
||||
|
||||
### Installation
|
||||
\`\`\`bash
|
||||
git clone https://github.com/jatinkrmalik/vocalinux.git && cd vocalinux && ./install.sh
|
||||
\`\`\`
|
||||
|
||||
```bash
|
||||
### Or step by step:
|
||||
|
||||
\`\`\`bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/jatinkrmalik/vocalinux.git
|
||||
cd vocalinux
|
||||
|
||||
# Run the installer script
|
||||
# Run the installer
|
||||
./install.sh
|
||||
```
|
||||
\`\`\`
|
||||
|
||||
The installer will set up everything you need, including system dependencies and a Python virtual environment.
|
||||
The installer handles everything: system dependencies, Python environment, speech models, and desktop integration.
|
||||
|
||||
For detailed installation instructions and options, see [docs/INSTALL.md](docs/INSTALL.md).
|
||||
### After Installation
|
||||
|
||||
### Running Vocalinux
|
||||
|
||||
After installation:
|
||||
|
||||
```bash
|
||||
\`\`\`bash
|
||||
# Activate the virtual environment
|
||||
source activate-vocalinux.sh
|
||||
|
||||
# Run Vocalinux
|
||||
vocalinux
|
||||
```
|
||||
\`\`\`
|
||||
|
||||
## Usage
|
||||
Or launch it from your application menu!
|
||||
|
||||
## 📋 Requirements
|
||||
|
||||
- **OS**: Ubuntu 22.04+ (other Linux distros may work)
|
||||
- **Python**: 3.8 or newer
|
||||
- **Display**: X11 or Wayland
|
||||
- **Hardware**: Microphone for voice input
|
||||
|
||||
## 🎙️ Usage
|
||||
|
||||
### Voice Dictation
|
||||
|
||||
1. Double-tap the Ctrl key to start recording
|
||||
1. **Double-tap Ctrl** to start recording
|
||||
2. Speak clearly into your microphone
|
||||
3. Double-tap Ctrl again or pause speaking to stop recording
|
||||
3. **Double-tap Ctrl** again (or pause speaking) to stop
|
||||
|
||||
### Voice Commands
|
||||
|
||||
| Command | Action |
|
||||
|---------|--------|
|
||||
| "new line" | Inserts a line break |
|
||||
| "period" | Types a period (.) |
|
||||
| "period" / "full stop" | Types a period (.) |
|
||||
| "comma" | Types a comma (,) |
|
||||
| "question mark" | Types a question mark (?) |
|
||||
| "exclamation mark" | Types an exclamation mark (!) |
|
||||
| "delete that" | Deletes the last sentence |
|
||||
| "capitalize" | Capitalizes the next word |
|
||||
|
||||
For a complete user guide, see [docs/USER_GUIDE.md](docs/USER_GUIDE.md).
|
||||
### Command Line Options
|
||||
|
||||
## Configuration
|
||||
\`\`\`bash
|
||||
vocalinux --help # Show all options
|
||||
vocalinux --debug # Enable debug logging
|
||||
vocalinux --engine whisper # Use Whisper AI engine
|
||||
vocalinux --model medium # Use medium-sized model
|
||||
vocalinux --wayland # Force Wayland mode
|
||||
\`\`\`
|
||||
|
||||
Configuration is stored in `~/.config/vocalinux/config.json`. You can customize:
|
||||
## ⚙️ Configuration
|
||||
|
||||
- Recognition engine (VOSK or Whisper)
|
||||
- Model size (small, medium, large)
|
||||
- Keyboard shortcuts
|
||||
- Audio feedback
|
||||
- UI behavior
|
||||
- And more
|
||||
Configuration is stored in \`~/.config/vocalinux/config.json\`:
|
||||
|
||||
## Features
|
||||
\`\`\`json
|
||||
{
|
||||
"speech_recognition": {
|
||||
"engine": "vosk",
|
||||
"model_size": "small",
|
||||
"vad_sensitivity": 3,
|
||||
"silence_timeout": 2.0
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### Custom Icons
|
||||
You can also configure settings through the graphical Settings dialog (right-click the tray icon).
|
||||
|
||||
Vocalinux includes custom icons for system trays and application launchers that visually indicate:
|
||||
- Microphone inactive state
|
||||
- Microphone active state
|
||||
- Speech processing state
|
||||
## 🔧 Development Setup
|
||||
|
||||
### Custom Sounds
|
||||
\`\`\`bash
|
||||
# Clone and install in dev mode
|
||||
git clone https://github.com/jatinkrmalik/vocalinux.git
|
||||
cd vocalinux
|
||||
./install.sh --dev
|
||||
|
||||
Audio feedback is provided for:
|
||||
- Start of recording
|
||||
- End of recording
|
||||
- Error conditions
|
||||
# Activate environment
|
||||
source activate-vocalinux.sh
|
||||
|
||||
You can customize these sounds by replacing the files in `resources/sounds/`.
|
||||
# Run tests
|
||||
pytest
|
||||
|
||||
## Advanced Usage
|
||||
# Run from source with debug
|
||||
python -m vocalinux.main --debug
|
||||
\`\`\`
|
||||
|
||||
```bash
|
||||
# With debugging enabled
|
||||
vocalinux --debug
|
||||
## 📁 Project Structure
|
||||
|
||||
# With a specific speech recognition engine
|
||||
vocalinux --engine whisper
|
||||
\`\`\`
|
||||
vocalinux/
|
||||
├── src/vocalinux/ # Main application code
|
||||
│ ├── speech_recognition/ # Speech recognition engines
|
||||
│ ├── text_injection/ # Text injection (X11/Wayland)
|
||||
│ ├── ui/ # GTK UI components
|
||||
│ └── utils/ # Utility functions
|
||||
├── tests/ # Test suite
|
||||
├── resources/ # Icons and sounds
|
||||
├── docs/ # Documentation
|
||||
└── web/ # Website source
|
||||
\`\`\`
|
||||
|
||||
# With a specific model size
|
||||
vocalinux --model medium
|
||||
|
||||
# Force Wayland compatibility mode
|
||||
vocalinux --wayland
|
||||
```
|
||||
|
||||
## Documentation
|
||||
## 📖 Documentation
|
||||
|
||||
- [Installation Guide](docs/INSTALL.md) - Detailed installation instructions
|
||||
- [User Guide](docs/USER_GUIDE.md) - Complete user documentation
|
||||
- [Contributing](CONTRIBUTING.md) - Development setup and contribution guidelines
|
||||
|
||||
## Roadmap
|
||||
## 🗺️ Roadmap
|
||||
|
||||
Future development plans include:
|
||||
- [x] ~~Custom icon design~~ ✅
|
||||
- [x] ~~Graphical settings dialog~~ ✅
|
||||
- [x] ~~Whisper AI support~~ ✅
|
||||
- [ ] Multi-language support
|
||||
- [ ] Application-specific commands
|
||||
- [ ] Debian/Ubuntu package (.deb)
|
||||
- [ ] Improved Wayland support
|
||||
- [ ] Voice command customization
|
||||
|
||||
1. ~~Custom icon design~~ ✅ (Implemented in v0.1.0)
|
||||
2. Graphical settings dialog
|
||||
3. Advanced voice commands for specific applications
|
||||
4. Multi-language support
|
||||
5. Better integration with popular applications
|
||||
6. Improved model management
|
||||
7. Customizable keyboard shortcuts via GUI
|
||||
8. More audio feedback options
|
||||
## 🤝 Contributing
|
||||
|
||||
## Contributing
|
||||
We welcome contributions! Whether it's bug reports, feature requests, or code contributions, please check out our [Contributing Guide](CONTRIBUTING.md).
|
||||
|
||||
Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for development setup instructions and contribution guidelines.
|
||||
### Quick Links
|
||||
|
||||
## License
|
||||
- 🐛 [Report a Bug](https://github.com/jatinkrmalik/vocalinux/issues/new?template=bug_report.md)
|
||||
- 💡 [Request a Feature](https://github.com/jatinkrmalik/vocalinux/issues/new?template=feature_request.md)
|
||||
- 💬 [Discussions](https://github.com/jatinkrmalik/vocalinux/discussions)
|
||||
|
||||
This project is licensed under the GNU General Public License v3.0 - see the [LICENSE](LICENSE) file for details.
|
||||
## 👨💻 Author
|
||||
|
||||
**Jatin K Malik**
|
||||
- GitHub: [@jatinkrmalik](https://github.com/jatinkrmalik)
|
||||
- Twitter: [@jatinkrmalik](https://twitter.com/jatinkrmalik)
|
||||
|
||||
## ⭐ Support
|
||||
|
||||
If you find Vocalinux useful, please consider:
|
||||
- ⭐ Starring this repository
|
||||
- 🐛 Reporting bugs you encounter
|
||||
- 📖 Improving documentation
|
||||
- 🔀 Contributing code
|
||||
|
||||
## 📜 License
|
||||
|
||||
This project is licensed under the **GNU General Public License v3.0** - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
Made with ❤️ for the Linux community
|
||||
<br>
|
||||
<a href="https://github.com/jatinkrmalik/vocalinux">github.com/jatinkrmalik/vocalinux</a>
|
||||
</p>
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 0.2.x | :white_check_mark: |
|
||||
| < 0.2 | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
We take security seriously. If you discover a security vulnerability within Vocalinux, please follow these steps:
|
||||
|
||||
### Do NOT
|
||||
|
||||
- Open a public GitHub issue for security vulnerabilities
|
||||
- Disclose the vulnerability publicly before it's fixed
|
||||
|
||||
### Do
|
||||
|
||||
1. **Email the maintainer directly**: Send an email to jatinkrmalik@gmail.com with:
|
||||
- A clear description of the vulnerability
|
||||
- Steps to reproduce the issue
|
||||
- Potential impact assessment
|
||||
- Any suggested fixes (optional)
|
||||
|
||||
2. **Use the subject line**: "Vocalinux Security Vulnerability Report"
|
||||
|
||||
3. **Wait for acknowledgment**: You should receive a response within 48 hours
|
||||
|
||||
### What to Expect
|
||||
|
||||
1. **Acknowledgment**: We'll confirm receipt within 48 hours
|
||||
2. **Assessment**: We'll evaluate the severity and impact
|
||||
3. **Timeline**: We'll provide an estimated fix timeline
|
||||
4. **Fix**: We'll work on a fix and coordinate disclosure
|
||||
5. **Credit**: If desired, we'll credit you in the release notes
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Privacy
|
||||
|
||||
Vocalinux is designed with privacy in mind:
|
||||
|
||||
- **Offline by default**: The VOSK engine works completely offline
|
||||
- **Local processing**: All speech recognition happens on your device
|
||||
- **No data collection**: We don't collect or transmit your voice data
|
||||
- **No telemetry**: We don't track usage or behavior
|
||||
|
||||
### Whisper AI
|
||||
|
||||
When using Whisper, be aware:
|
||||
- Whisper processes locally on your machine
|
||||
- Models are downloaded once and cached locally
|
||||
- No audio data is sent to external servers
|
||||
|
||||
### File Permissions
|
||||
|
||||
The application stores data in:
|
||||
- `~/.config/vocalinux/` - Configuration (mode 700)
|
||||
- `~/.local/share/vocalinux/` - Data and models (mode 700)
|
||||
|
||||
### Known Limitations
|
||||
|
||||
1. **Text injection**: Uses xdotool/wtype which may have implications for input monitoring
|
||||
2. **Keyboard shortcuts**: Global keyboard hooks require appropriate permissions
|
||||
3. **Virtual environments**: Running in a venv provides some isolation
|
||||
|
||||
## Updates
|
||||
|
||||
Keep your installation updated to receive security fixes:
|
||||
|
||||
```bash
|
||||
cd vocalinux
|
||||
git pull origin main
|
||||
pip install --upgrade -e .
|
||||
```
|
||||
|
||||
## Contact
|
||||
|
||||
For security concerns: jatinkrmalik@gmail.com
|
||||
|
||||
For general issues: https://github.com/jatinkrmalik/vocalinux/issues
|
||||
+218
-105
@@ -2,50 +2,84 @@
|
||||
|
||||
This guide provides detailed instructions for installing Vocalinux on Linux systems.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### One-liner Installation
|
||||
|
||||
```bash
|
||||
git clone https://github.com/jatinkrmalik/vocalinux.git && cd vocalinux && ./install.sh
|
||||
```
|
||||
|
||||
That's it! The installer handles everything automatically.
|
||||
|
||||
## System Requirements
|
||||
|
||||
- **Operating System**: Ubuntu 22.04 or newer (may work on other Linux distributions)
|
||||
- **Python**: Version 3.8 or newer
|
||||
- **Display Server**: X11 or Wayland desktop environment
|
||||
- **Hardware**: Microphone for speech input
|
||||
| Requirement | Details |
|
||||
|-------------|---------|
|
||||
| **Operating System** | Ubuntu 22.04+ (recommended), Debian 11+, Fedora 38+, Arch Linux |
|
||||
| **Python** | 3.8 or newer |
|
||||
| **Display Server** | X11 or Wayland |
|
||||
| **Hardware** | Microphone for speech input |
|
||||
| **Disk Space** | ~500MB (including speech models) |
|
||||
| **RAM** | 4GB minimum, 8GB recommended for Whisper |
|
||||
|
||||
## Standard Installation
|
||||
## Installation Options
|
||||
|
||||
The recommended way to install Vocalinux is using the provided installation script:
|
||||
### Standard Installation
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/jatinkrmalik/vocalinux.git
|
||||
cd vocalinux
|
||||
|
||||
# Run the installer script
|
||||
# Run the installer
|
||||
./install.sh
|
||||
```
|
||||
|
||||
### What the Installer Does
|
||||
### Installation with Whisper Support
|
||||
|
||||
The installation script:
|
||||
1. Installs required system dependencies
|
||||
2. Creates a Python virtual environment
|
||||
3. Configures application directories
|
||||
4. Installs custom icons and desktop entries
|
||||
5. Sets up everything you need to run Vocalinux
|
||||
Whisper provides better accuracy but requires more resources:
|
||||
|
||||
### Installation Options
|
||||
```bash
|
||||
./install.sh --with-whisper
|
||||
```
|
||||
|
||||
The installer script supports several options:
|
||||
### Development Installation
|
||||
|
||||
For contributing or development work:
|
||||
|
||||
```bash
|
||||
./install.sh --dev
|
||||
```
|
||||
|
||||
This installs additional tools: pytest, black, isort, flake8, pre-commit.
|
||||
|
||||
### All Installer Options
|
||||
|
||||
```bash
|
||||
# See all available options
|
||||
./install.sh --help
|
||||
|
||||
# Specify a custom virtual environment directory
|
||||
./install.sh --venv-dir=custom_venv_name
|
||||
Options:
|
||||
--dev Install in development mode with all dev dependencies
|
||||
--test Run tests after installation
|
||||
--venv-dir=PATH Specify custom virtual environment directory (default: venv)
|
||||
--skip-models Skip downloading VOSK models during installation
|
||||
--with-whisper Install Whisper AI support (requires more disk space)
|
||||
--help Show this help message
|
||||
```
|
||||
|
||||
## What the Installer Does
|
||||
|
||||
1. **Detects your Linux distribution** and installs appropriate system packages
|
||||
2. **Creates a Python virtual environment** with system site-packages access
|
||||
3. **Installs the Vocalinux package** and all dependencies
|
||||
4. **Downloads speech recognition models** (VOSK small model by default)
|
||||
5. **Installs desktop integration** (icons, .desktop file)
|
||||
6. **Creates activation script** for easy environment activation
|
||||
|
||||
## Running Vocalinux
|
||||
|
||||
After installation, you need to activate the virtual environment before using Vocalinux:
|
||||
After installation:
|
||||
|
||||
```bash
|
||||
# Activate the virtual environment
|
||||
@@ -55,151 +89,230 @@ source activate-vocalinux.sh
|
||||
vocalinux
|
||||
```
|
||||
|
||||
You can also launch Vocalinux from your application menu.
|
||||
|
||||
## Command Line Options
|
||||
|
||||
Vocalinux supports several command-line options:
|
||||
### Command Line Options
|
||||
|
||||
```bash
|
||||
# With debugging enabled
|
||||
vocalinux --debug
|
||||
|
||||
# With a specific speech recognition engine
|
||||
vocalinux --engine whisper
|
||||
|
||||
# With a specific model size
|
||||
vocalinux --model medium
|
||||
|
||||
# Force Wayland compatibility mode
|
||||
vocalinux --wayland
|
||||
vocalinux --help # Show all options
|
||||
vocalinux --debug # Enable debug logging
|
||||
vocalinux --engine whisper # Use Whisper AI engine
|
||||
vocalinux --engine vosk # Use VOSK engine (default)
|
||||
vocalinux --model small # Use small model
|
||||
vocalinux --model medium # Use medium model
|
||||
vocalinux --model large # Use large model
|
||||
vocalinux --wayland # Force Wayland compatibility mode
|
||||
```
|
||||
|
||||
## Directory Structure
|
||||
|
||||
Vocalinux follows the [XDG Base Directory Specification](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html) and stores files in these locations:
|
||||
Vocalinux follows the [XDG Base Directory Specification](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html):
|
||||
|
||||
- `~/.config/vocalinux/` - Configuration files
|
||||
- `~/.local/share/vocalinux/` - Application data (models, etc.)
|
||||
- `~/.local/share/applications/` - Desktop entry
|
||||
- `~/.local/share/icons/hicolor/scalable/apps/` - System-wide icons
|
||||
| Directory | Purpose |
|
||||
|-----------|---------|
|
||||
| `~/.config/vocalinux/` | Configuration files |
|
||||
| `~/.local/share/vocalinux/` | Application data, speech models |
|
||||
| `~/.local/share/applications/` | Desktop entry |
|
||||
| `~/.local/share/icons/hicolor/scalable/apps/` | Application icons |
|
||||
|
||||
## Manual Installation
|
||||
|
||||
If you prefer to install manually:
|
||||
If you prefer manual installation or the automatic installer doesn't work:
|
||||
|
||||
### 1. Install System Dependencies
|
||||
|
||||
**Ubuntu/Debian:**
|
||||
```bash
|
||||
# Required system dependencies
|
||||
sudo apt update
|
||||
sudo apt install -y python3-pip python3-gi python3-gi-cairo gir1.2-gtk-3.0 \
|
||||
gir1.2-appindicator3-0.1 libgirepository1.0-dev python3-dev portaudio19-dev python3-venv
|
||||
sudo apt install -y \
|
||||
python3-pip python3-venv python3-dev \
|
||||
python3-gi python3-gi-cairo \
|
||||
gir1.2-gtk-3.0 gir1.2-appindicator3-0.1 \
|
||||
libgirepository1.0-dev portaudio19-dev \
|
||||
wget curl unzip
|
||||
|
||||
# For X11 environments
|
||||
# For X11
|
||||
sudo apt install -y xdotool
|
||||
|
||||
# For Wayland environments
|
||||
# For Wayland
|
||||
sudo apt install -y wtype
|
||||
```
|
||||
|
||||
### 2. Set Up Python Environment
|
||||
**Fedora:**
|
||||
```bash
|
||||
sudo dnf install -y \
|
||||
python3-pip python3-devel python3-virtualenv \
|
||||
python3-gobject gtk3 libappindicator-gtk3 \
|
||||
gobject-introspection-devel portaudio-devel \
|
||||
wget curl unzip xdotool
|
||||
```
|
||||
|
||||
**Arch Linux:**
|
||||
```bash
|
||||
sudo pacman -S --noconfirm \
|
||||
python-pip python-gobject gtk3 \
|
||||
libappindicator-gtk3 gobject-introspection \
|
||||
python-cairo portaudio python-virtualenv \
|
||||
wget curl unzip xdotool
|
||||
```
|
||||
|
||||
### 2. Create Virtual Environment
|
||||
|
||||
```bash
|
||||
# Create a virtual environment
|
||||
cd vocalinux
|
||||
python3 -m venv venv --system-site-packages
|
||||
source venv/bin/activate
|
||||
|
||||
# Update pip and setuptools
|
||||
pip install --upgrade pip setuptools wheel
|
||||
```
|
||||
|
||||
### 3. Install Python Package
|
||||
### 3. Install Package
|
||||
|
||||
```bash
|
||||
# Basic installation
|
||||
# Standard installation
|
||||
pip install .
|
||||
|
||||
# With Whisper support (requires more resources)
|
||||
# With Whisper support
|
||||
pip install ".[whisper]"
|
||||
|
||||
# Development mode
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
### 4. Install Icons and Desktop Entry
|
||||
### 4. Set Up Desktop Integration
|
||||
|
||||
```bash
|
||||
# Define XDG directories
|
||||
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/vocalinux"
|
||||
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/vocalinux"
|
||||
DESKTOP_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/applications"
|
||||
ICON_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/scalable/apps"
|
||||
# Create directories
|
||||
mkdir -p ~/.config/vocalinux
|
||||
mkdir -p ~/.local/share/vocalinux/models
|
||||
mkdir -p ~/.local/share/applications
|
||||
mkdir -p ~/.local/share/icons/hicolor/scalable/apps
|
||||
|
||||
# Create necessary directories
|
||||
mkdir -p "$CONFIG_DIR"
|
||||
mkdir -p "$DATA_DIR/models"
|
||||
mkdir -p "$DESKTOP_DIR"
|
||||
mkdir -p "$ICON_DIR"
|
||||
# Install desktop entry
|
||||
cp vocalinux.desktop ~/.local/share/applications/
|
||||
VENV_PATH=$(realpath venv/bin/vocalinux)
|
||||
sed -i "s|^Exec=vocalinux|Exec=$VENV_PATH|" ~/.local/share/applications/vocalinux.desktop
|
||||
|
||||
# Copy desktop entry and update to use the venv
|
||||
cp vocalinux.desktop "$DESKTOP_DIR/"
|
||||
VENV_SCRIPT_PATH="$(realpath venv/bin/vocalinux)"
|
||||
sed -i "s|^Exec=vocalinux|Exec=$VENV_SCRIPT_PATH|" "$DESKTOP_DIR/vocalinux.desktop"
|
||||
|
||||
# Copy icons
|
||||
cp resources/icons/scalable/vocalinux.svg "$ICON_DIR/"
|
||||
cp resources/icons/scalable/vocalinux-microphone.svg "$ICON_DIR/"
|
||||
cp resources/icons/scalable/vocalinux-microphone-off.svg "$ICON_DIR/"
|
||||
cp resources/icons/scalable/vocalinux-microphone-process.svg "$ICON_DIR/"
|
||||
# Install icons
|
||||
cp resources/icons/scalable/*.svg ~/.local/share/icons/hicolor/scalable/apps/
|
||||
|
||||
# Update icon cache
|
||||
gtk-update-icon-cache -f -t "${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor" 2>/dev/null || true
|
||||
gtk-update-icon-cache -f -t ~/.local/share/icons/hicolor
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Virtual Environment Issues
|
||||
|
||||
If you encounter problems with the virtual environment:
|
||||
- Ensure you've activated it with `source activate-vocalinux.sh`
|
||||
- If you get "command not found" errors, verify the virtual environment has been properly created
|
||||
**Symptom:** "command not found: vocalinux"
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Make sure you've activated the environment
|
||||
source activate-vocalinux.sh
|
||||
|
||||
# Or activate directly
|
||||
source venv/bin/activate
|
||||
```
|
||||
|
||||
### Audio Input Problems
|
||||
|
||||
- Check that your microphone is connected and working
|
||||
- Verify your system audio settings
|
||||
- Run with `--debug` flag to see detailed logs
|
||||
**Symptom:** "No audio detected" or microphone not working
|
||||
|
||||
### Text Injection Issues
|
||||
**Solutions:**
|
||||
1. Check system audio settings
|
||||
2. Run `arecord -l` to list audio devices
|
||||
3. Try with debug mode: `vocalinux --debug`
|
||||
4. Check microphone permissions
|
||||
|
||||
- For X11: Ensure xdotool is installed
|
||||
- For Wayland: Ensure wtype is installed
|
||||
- Some applications may have security measures that prevent text injection
|
||||
### GTK/AppIndicator Errors
|
||||
|
||||
**Symptom:** "No module named gi" or AppIndicator errors
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Reinstall GTK dependencies
|
||||
sudo apt install python3-gi python3-gi-cairo gir1.2-appindicator3-0.1
|
||||
|
||||
# Recreate venv with system packages
|
||||
rm -rf venv
|
||||
python3 -m venv venv --system-site-packages
|
||||
source venv/bin/activate
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
### Text Injection Not Working
|
||||
|
||||
**Symptom:** Recognized text doesn't appear in applications
|
||||
|
||||
**Solutions:**
|
||||
|
||||
For X11:
|
||||
```bash
|
||||
sudo apt install xdotool
|
||||
# Test: xdotool type "hello"
|
||||
```
|
||||
|
||||
For Wayland:
|
||||
```bash
|
||||
sudo apt install wtype
|
||||
# Test: wtype "hello"
|
||||
```
|
||||
|
||||
### Model Download Fails
|
||||
|
||||
**Symptom:** Can't download speech models
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Models will auto-download on first run
|
||||
# Or manually download:
|
||||
mkdir -p ~/.local/share/vocalinux/models
|
||||
cd ~/.local/share/vocalinux/models
|
||||
wget https://alphacephei.com/vosk/models/vosk-model-small-en-us-0.15.zip
|
||||
unzip vosk-model-small-en-us-0.15.zip
|
||||
```
|
||||
|
||||
### Icons Not Displaying
|
||||
|
||||
- Run `gtk-update-icon-cache -f -t ~/.local/share/icons/hicolor` to refresh the icon cache
|
||||
- Ensure the SVG icons are properly installed in the correct directory
|
||||
**Symptom:** Tray icon missing or generic
|
||||
|
||||
### Recognition Accuracy
|
||||
**Solution:**
|
||||
```bash
|
||||
# Refresh icon cache
|
||||
gtk-update-icon-cache -f -t ~/.local/share/icons/hicolor
|
||||
|
||||
- Try a larger model size (`--model medium` or `--model large`)
|
||||
- Ensure your microphone is positioned correctly
|
||||
- Speak clearly and at a moderate pace
|
||||
# Restart the application
|
||||
```
|
||||
|
||||
## Uninstallation
|
||||
|
||||
To uninstall Vocalinux:
|
||||
### Using the Uninstaller
|
||||
|
||||
1. Remove the application directories:
|
||||
```bash
|
||||
rm -rf ~/.config/vocalinux
|
||||
rm -rf ~/.local/share/vocalinux
|
||||
rm ~/.local/share/applications/vocalinux.desktop
|
||||
rm ~/.local/share/icons/hicolor/scalable/apps/vocalinux*.svg
|
||||
```
|
||||
```bash
|
||||
./uninstall.sh
|
||||
```
|
||||
|
||||
2. Update the icon cache:
|
||||
```bash
|
||||
gtk-update-icon-cache -f -t ~/.local/share/icons/hicolor
|
||||
```
|
||||
Options:
|
||||
- `--keep-config` - Keep configuration files
|
||||
- `--keep-data` - Keep application data (models, etc.)
|
||||
|
||||
3. Remove the cloned repository and virtual environment (if you wish).
|
||||
### Manual Uninstallation
|
||||
|
||||
```bash
|
||||
# Remove application files
|
||||
rm -rf venv
|
||||
rm -f activate-vocalinux.sh
|
||||
|
||||
# Remove user data
|
||||
rm -rf ~/.config/vocalinux
|
||||
rm -rf ~/.local/share/vocalinux
|
||||
rm -f ~/.local/share/applications/vocalinux.desktop
|
||||
rm -f ~/.local/share/icons/hicolor/scalable/apps/vocalinux*.svg
|
||||
|
||||
# Update icon cache
|
||||
gtk-update-icon-cache -f -t ~/.local/share/icons/hicolor
|
||||
```
|
||||
|
||||
## Getting Help
|
||||
|
||||
- 📖 [User Guide](USER_GUIDE.md)
|
||||
- 🐛 [Report Issues](https://github.com/jatinkrmalik/vocalinux/issues)
|
||||
- 💬 [Discussions](https://github.com/jatinkrmalik/vocalinux/discussions)
|
||||
|
||||
+153
@@ -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"]
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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."""
|
||||
...
|
||||
|
||||
|
||||
@@ -39,9 +39,7 @@ def parse_arguments():
|
||||
choices=["vosk", "whisper"],
|
||||
help="Speech recognition engine to use",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--wayland", action="store_true", help="Force Wayland compatibility mode"
|
||||
)
|
||||
parser.add_argument("--wayland", action="store_true", help="Force Wayland compatibility mode")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
|
||||
@@ -83,25 +83,19 @@ class CommandProcessor:
|
||||
"""Compile regex patterns for command matching."""
|
||||
# Create regex pattern for text commands
|
||||
text_cmd_pattern = (
|
||||
r"\b("
|
||||
+ "|".join(re.escape(cmd) for cmd in self.text_commands.keys())
|
||||
+ r")\b"
|
||||
r"\b(" + "|".join(re.escape(cmd) for cmd in self.text_commands.keys()) + r")\b"
|
||||
)
|
||||
self.text_cmd_regex = re.compile(text_cmd_pattern, re.IGNORECASE)
|
||||
|
||||
# Create regex pattern for action commands
|
||||
action_cmd_pattern = (
|
||||
r"\b("
|
||||
+ "|".join(re.escape(cmd) for cmd in self.action_commands.keys())
|
||||
+ r")\b"
|
||||
r"\b(" + "|".join(re.escape(cmd) for cmd in self.action_commands.keys()) + r")\b"
|
||||
)
|
||||
self.action_cmd_regex = re.compile(action_cmd_pattern, re.IGNORECASE)
|
||||
|
||||
# Create regex pattern for format commands
|
||||
format_cmd_pattern = (
|
||||
r"\b("
|
||||
+ "|".join(re.escape(cmd) for cmd in self.format_commands.keys())
|
||||
+ r")\b"
|
||||
r"\b(" + "|".join(re.escape(cmd) for cmd in self.format_commands.keys()) + r")\b"
|
||||
)
|
||||
self.format_cmd_regex = re.compile(format_cmd_pattern, re.IGNORECASE)
|
||||
|
||||
@@ -238,9 +232,7 @@ class CommandProcessor:
|
||||
actions.append(action)
|
||||
|
||||
# Check if there's text after the command
|
||||
match = re.search(
|
||||
r"\b" + re.escape(cmd) + r"\s+(.*?)\b", text, re.IGNORECASE
|
||||
)
|
||||
match = re.search(r"\b" + re.escape(cmd) + r"\s+(.*?)\b", text, re.IGNORECASE)
|
||||
if match:
|
||||
processed_text = " " + match.group(1)
|
||||
else:
|
||||
@@ -281,9 +273,7 @@ class CommandProcessor:
|
||||
|
||||
if re.search(cmd_pattern, text, re.IGNORECASE):
|
||||
# Handle format command that modifies next word
|
||||
match = re.search(
|
||||
r"\b" + re.escape(cmd) + r"\s+(\w+)", text, re.IGNORECASE
|
||||
)
|
||||
match = re.search(r"\b" + re.escape(cmd) + r"\s+(\w+)", text, re.IGNORECASE)
|
||||
if match:
|
||||
word = match.group(1)
|
||||
if format_type == "capitalize_next":
|
||||
|
||||
@@ -53,6 +53,22 @@ from .command_processor import CommandProcessor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _show_notification(title: str, message: str, icon: str = "dialog-warning"):
|
||||
"""Show a desktop notification."""
|
||||
try:
|
||||
import subprocess
|
||||
|
||||
# Use notify-send which is available on most Linux desktops
|
||||
subprocess.Popen(
|
||||
["notify-send", "-i", icon, "-a", "Vocalinux", title, message],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not show notification: {e}")
|
||||
|
||||
|
||||
# Define constants
|
||||
MODELS_DIR = os.path.expanduser("~/.local/share/vocalinux/models")
|
||||
# Alternative locations for pre-installed models
|
||||
@@ -70,13 +86,16 @@ class SpeechRecognitionManager:
|
||||
speech recognition engines (VOSK and Whisper).
|
||||
"""
|
||||
|
||||
def __init__(self, engine: str = "vosk", model_size: str = "small", **kwargs):
|
||||
def __init__(
|
||||
self, engine: str = "vosk", model_size: str = "small", defer_download: bool = True, **kwargs
|
||||
):
|
||||
"""
|
||||
Initialize the speech recognition manager.
|
||||
|
||||
Args:
|
||||
engine: The speech recognition engine to use ("vosk" or "whisper")
|
||||
model_size: The size of the model to use ("small", "medium", "large")
|
||||
defer_download: If True, don't download missing models at startup (default: True)
|
||||
"""
|
||||
self.engine = engine
|
||||
self.model_size = model_size
|
||||
@@ -90,6 +109,12 @@ class SpeechRecognitionManager:
|
||||
self.state_callbacks: List[Callable[[RecognitionState], None]] = []
|
||||
self.action_callbacks: List[Callable[[str], None]] = []
|
||||
|
||||
# Download progress tracking
|
||||
self._download_progress_callback: Optional[Callable[[float, float, str], None]] = None
|
||||
self._download_cancelled = False
|
||||
self._defer_download = defer_download
|
||||
self._model_initialized = False
|
||||
|
||||
# Speech detection parameters (load defaults, will be overridden by configure)
|
||||
self.vad_sensitivity = kwargs.get("vad_sensitivity", 3)
|
||||
self.silence_timeout = kwargs.get("silence_timeout", 2.0)
|
||||
@@ -101,9 +126,7 @@ class SpeechRecognitionManager:
|
||||
# Create models directory if it doesn't exist
|
||||
os.makedirs(MODELS_DIR, exist_ok=True)
|
||||
|
||||
logger.info(
|
||||
f"Initializing speech recognition with {engine} engine and {model_size} model"
|
||||
)
|
||||
logger.info(f"Initializing speech recognition with {engine} engine and {model_size} model")
|
||||
|
||||
# Initialize the selected speech recognition engine
|
||||
if engine == "vosk":
|
||||
@@ -121,31 +144,25 @@ class SpeechRecognitionManager:
|
||||
self.vosk_model_path = self._get_vosk_model_path()
|
||||
|
||||
if not os.path.exists(self.vosk_model_path):
|
||||
logger.info(
|
||||
f"VOSK model not found at {self.vosk_model_path}. Downloading..."
|
||||
)
|
||||
self._download_vosk_model()
|
||||
# Update path after download
|
||||
self.vosk_model_path = self._get_vosk_model_path()
|
||||
if self._defer_download:
|
||||
logger.info(
|
||||
f"VOSK model not found at {self.vosk_model_path}. Will download when needed."
|
||||
)
|
||||
self._model_initialized = False
|
||||
return # Don't block startup
|
||||
else:
|
||||
logger.info(f"VOSK model not found at {self.vosk_model_path}. Downloading...")
|
||||
self._download_vosk_model()
|
||||
# Update path after download
|
||||
self.vosk_model_path = self._get_vosk_model_path()
|
||||
else:
|
||||
# Check if this is a pre-installed model
|
||||
if any(
|
||||
self.vosk_model_path.startswith(sys_dir)
|
||||
for sys_dir in SYSTEM_MODELS_DIRS
|
||||
):
|
||||
logger.info(
|
||||
f"Using pre-installed VOSK model from {self.vosk_model_path}"
|
||||
)
|
||||
elif os.path.exists(
|
||||
os.path.join(self.vosk_model_path, ".vocalinux_preinstalled")
|
||||
):
|
||||
logger.info(
|
||||
f"Using installer-provided VOSK model from {self.vosk_model_path}"
|
||||
)
|
||||
if any(self.vosk_model_path.startswith(sys_dir) for sys_dir in SYSTEM_MODELS_DIRS):
|
||||
logger.info(f"Using pre-installed VOSK model from {self.vosk_model_path}")
|
||||
elif os.path.exists(os.path.join(self.vosk_model_path, ".vocalinux_preinstalled")):
|
||||
logger.info(f"Using installer-provided VOSK model from {self.vosk_model_path}")
|
||||
else:
|
||||
logger.info(
|
||||
f"Using existing VOSK model from {self.vosk_model_path}"
|
||||
)
|
||||
logger.info(f"Using existing VOSK model from {self.vosk_model_path}")
|
||||
|
||||
logger.info(f"Loading VOSK model from {self.vosk_model_path}")
|
||||
# Ensure previous model/recognizer are released if re-initializing
|
||||
@@ -153,12 +170,11 @@ class SpeechRecognitionManager:
|
||||
self.recognizer = None
|
||||
self.model = Model(self.vosk_model_path)
|
||||
self.recognizer = KaldiRecognizer(self.model, 16000)
|
||||
self._model_initialized = True
|
||||
logger.info("VOSK engine initialized successfully.")
|
||||
|
||||
except ImportError:
|
||||
logger.error(
|
||||
"Failed to import VOSK. Please install it with 'pip install vosk'"
|
||||
)
|
||||
logger.error("Failed to import VOSK. Please install it with 'pip install vosk'")
|
||||
self.state = RecognitionState.ERROR
|
||||
raise
|
||||
|
||||
@@ -183,14 +199,31 @@ class SpeechRecognitionManager:
|
||||
)
|
||||
self.model_size = "base"
|
||||
|
||||
# Check if model is downloaded
|
||||
whisper_cache_dir = os.path.join(MODELS_DIR, "whisper")
|
||||
os.makedirs(whisper_cache_dir, exist_ok=True)
|
||||
model_file = os.path.join(whisper_cache_dir, f"{self.model_size}.pt")
|
||||
default_cache = os.path.expanduser("~/.cache/whisper")
|
||||
default_model_file = os.path.join(default_cache, f"{self.model_size}.pt")
|
||||
|
||||
model_exists = os.path.exists(model_file) or os.path.exists(default_model_file)
|
||||
|
||||
if not model_exists and self._defer_download:
|
||||
logger.info(
|
||||
f"Whisper model '{self.model_size}' not found. Will download when needed."
|
||||
)
|
||||
self._model_initialized = False
|
||||
return # Don't block startup
|
||||
|
||||
# If model doesn't exist and we're not deferring, download it with progress
|
||||
if not model_exists:
|
||||
logger.info(f"Downloading Whisper '{self.model_size}' model...")
|
||||
self._download_whisper_model(whisper_cache_dir)
|
||||
|
||||
# Determine device (GPU if available, otherwise CPU)
|
||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
logger.info(f"Using device: {device}")
|
||||
|
||||
# Download directory for Whisper models
|
||||
whisper_cache_dir = os.path.join(MODELS_DIR, "whisper")
|
||||
os.makedirs(whisper_cache_dir, exist_ok=True)
|
||||
|
||||
logger.info(f"Loading Whisper '{self.model_size}' model...")
|
||||
# Ensure previous model is released if re-initializing
|
||||
self.model = None
|
||||
@@ -200,6 +233,7 @@ class SpeechRecognitionManager:
|
||||
self.model_size, device=device, download_root=whisper_cache_dir
|
||||
)
|
||||
|
||||
self._model_initialized = True
|
||||
logger.info(f"Whisper model loaded on {device.upper()}")
|
||||
logger.info("Whisper engine initialized successfully.")
|
||||
|
||||
@@ -298,12 +332,30 @@ class SpeechRecognitionManager:
|
||||
logger.debug(f"No existing model found, will use: {user_model_path}")
|
||||
return user_model_path
|
||||
|
||||
def set_download_progress_callback(
|
||||
self, callback: Optional[Callable[[float, float, str], None]]
|
||||
):
|
||||
"""
|
||||
Set a callback for download progress updates.
|
||||
|
||||
Args:
|
||||
callback: Function(progress_fraction, speed_mbps, status_text)
|
||||
or None to clear
|
||||
"""
|
||||
self._download_progress_callback = callback
|
||||
|
||||
def cancel_download(self):
|
||||
"""Request cancellation of the current download."""
|
||||
self._download_cancelled = True
|
||||
logger.info("Download cancellation requested")
|
||||
|
||||
def _download_vosk_model(self):
|
||||
"""Download the VOSK model if it doesn't exist."""
|
||||
import zipfile
|
||||
|
||||
import requests
|
||||
import tqdm
|
||||
|
||||
self._download_cancelled = False
|
||||
|
||||
model_urls = {
|
||||
"small": "https://alphacephei.com/vosk/models/vosk-model-small-en-us-0.15.zip",
|
||||
@@ -324,9 +376,7 @@ class SpeechRecognitionManager:
|
||||
# Create models directory if it doesn't exist
|
||||
os.makedirs(MODELS_DIR, exist_ok=True)
|
||||
|
||||
logger.info(
|
||||
f"Downloading VOSK {self.model_size} model to user directory: {model_path}"
|
||||
)
|
||||
logger.info(f"Downloading VOSK {self.model_size} model to user directory: {model_path}")
|
||||
|
||||
# Download the model
|
||||
logger.info(f"Downloading VOSK model from {url}")
|
||||
@@ -335,15 +385,63 @@ class SpeechRecognitionManager:
|
||||
response.raise_for_status() # Raise an exception for bad status codes (4xx or 5xx)
|
||||
|
||||
total_size = int(response.headers.get("content-length", 0))
|
||||
downloaded_size = 0
|
||||
start_time = time.time()
|
||||
last_update_time = start_time
|
||||
chunk_size = 8192 # 8KB chunks for smoother progress
|
||||
|
||||
with open(zip_path, "wb") as f:
|
||||
for data in tqdm.tqdm(
|
||||
response.iter_content(chunk_size=1024),
|
||||
total=total_size // 1024,
|
||||
unit="KB",
|
||||
desc=f"Downloading {model_name}", # Add description to progress bar
|
||||
):
|
||||
for data in response.iter_content(chunk_size=chunk_size):
|
||||
if self._download_cancelled:
|
||||
logger.info("Download cancelled by user")
|
||||
f.close()
|
||||
if os.path.exists(zip_path):
|
||||
os.remove(zip_path)
|
||||
raise RuntimeError("Download cancelled")
|
||||
|
||||
f.write(data)
|
||||
downloaded_size += len(data)
|
||||
|
||||
# Update progress callback
|
||||
current_time = time.time()
|
||||
if (
|
||||
self._download_progress_callback
|
||||
and (current_time - last_update_time) >= 0.1
|
||||
):
|
||||
elapsed = current_time - start_time
|
||||
if elapsed > 0:
|
||||
speed_mbps = (downloaded_size / (1024 * 1024)) / elapsed
|
||||
else:
|
||||
speed_mbps = 0
|
||||
|
||||
if total_size > 0:
|
||||
progress = downloaded_size / total_size
|
||||
remaining_mb = (total_size - downloaded_size) / (1024 * 1024)
|
||||
if speed_mbps > 0:
|
||||
eta_seconds = remaining_mb / speed_mbps
|
||||
eta_str = (
|
||||
f"{int(eta_seconds)}s"
|
||||
if eta_seconds < 60
|
||||
else f"{int(eta_seconds / 60)}m {int(eta_seconds % 60)}s"
|
||||
)
|
||||
else:
|
||||
eta_str = "--"
|
||||
status = f"{downloaded_size / (1024 * 1024):.1f} / {total_size / (1024 * 1024):.1f} MB • {speed_mbps:.1f} MB/s • ETA: {eta_str}"
|
||||
else:
|
||||
progress = 0
|
||||
status = (
|
||||
f"{downloaded_size / (1024 * 1024):.1f} MB • {speed_mbps:.1f} MB/s"
|
||||
)
|
||||
|
||||
self._download_progress_callback(progress, speed_mbps, status)
|
||||
last_update_time = current_time
|
||||
|
||||
# Also log progress periodically
|
||||
logger.info(f"Download progress: {progress * 100:.1f}% - {status}")
|
||||
|
||||
# Update status for extraction phase
|
||||
if self._download_progress_callback:
|
||||
self._download_progress_callback(1.0, 0, "Extracting model...")
|
||||
|
||||
# Extract the model
|
||||
logger.info(f"Extracting VOSK model to {model_path}")
|
||||
@@ -354,6 +452,10 @@ class SpeechRecognitionManager:
|
||||
os.remove(zip_path)
|
||||
logger.info("VOSK model downloaded and extracted successfully")
|
||||
|
||||
# Final status
|
||||
if self._download_progress_callback:
|
||||
self._download_progress_callback(1.0, 0, "Complete!")
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Failed to download VOSK model from {url}: {e}")
|
||||
# Clean up potentially incomplete download
|
||||
@@ -367,9 +469,7 @@ class SpeechRecognitionManager:
|
||||
os.remove(zip_path)
|
||||
raise RuntimeError("Downloaded VOSK model file is corrupted.")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"An error occurred during VOSK model download/extraction: {e}"
|
||||
)
|
||||
logger.error(f"An error occurred during VOSK model download/extraction: {e}")
|
||||
# Clean up potentially corrupted extraction
|
||||
if os.path.exists(zip_path):
|
||||
os.remove(zip_path)
|
||||
@@ -377,6 +477,111 @@ class SpeechRecognitionManager:
|
||||
# if os.path.exists(model_path): shutil.rmtree(model_path)
|
||||
raise
|
||||
|
||||
def _download_whisper_model(self, cache_dir: str):
|
||||
"""Download a Whisper model with progress tracking."""
|
||||
import hashlib
|
||||
|
||||
import requests
|
||||
|
||||
self._download_cancelled = False
|
||||
|
||||
# Whisper model URLs (from openai-whisper package)
|
||||
model_urls = {
|
||||
"tiny": "https://openaipublic.azureedge.net/main/whisper/models/65147644a518d12f04e32d6f3b26facc3f8dd46e5390956a9424a650c0ce22b9/tiny.pt",
|
||||
"base": "https://openaipublic.azureedge.net/main/whisper/models/ed3a0b6b1c0edf879ad9b11b1af5a0e6ab5db9205f891f668f8b0e6c6326e34e/base.pt",
|
||||
"small": "https://openaipublic.azureedge.net/main/whisper/models/9ecf779972d90ba49c06d968637d720dd632c55bbf19d441fb42bf17a411e794/small.pt",
|
||||
"medium": "https://openaipublic.azureedge.net/main/whisper/models/345ae4da62f9b3d59415adc60127b97c714f32e89e936602e85993674d08dcb1/medium.pt",
|
||||
"large": "https://openaipublic.azureedge.net/main/whisper/models/e5b1a55b89c1367dacf97e3e19bfd829a01529dbfdeefa8caeb59b3f1b81dadb/large-v3.pt",
|
||||
}
|
||||
|
||||
url = model_urls.get(self.model_size)
|
||||
if not url:
|
||||
raise ValueError(f"Unknown Whisper model size: {self.model_size}")
|
||||
|
||||
model_file = os.path.join(cache_dir, f"{self.model_size}.pt")
|
||||
temp_file = model_file + ".tmp"
|
||||
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
|
||||
logger.info(f"Downloading Whisper {self.model_size} model to {model_file}")
|
||||
logger.info(f"Downloading from {url}")
|
||||
|
||||
try:
|
||||
response = requests.get(url, stream=True)
|
||||
response.raise_for_status()
|
||||
|
||||
total_size = int(response.headers.get("content-length", 0))
|
||||
downloaded_size = 0
|
||||
start_time = time.time()
|
||||
last_update_time = start_time
|
||||
chunk_size = 8192 # 8KB chunks
|
||||
|
||||
with open(temp_file, "wb") as f:
|
||||
for data in response.iter_content(chunk_size=chunk_size):
|
||||
if self._download_cancelled:
|
||||
logger.info("Download cancelled by user")
|
||||
f.close()
|
||||
if os.path.exists(temp_file):
|
||||
os.remove(temp_file)
|
||||
raise RuntimeError("Download cancelled")
|
||||
|
||||
f.write(data)
|
||||
downloaded_size += len(data)
|
||||
|
||||
# Update progress callback
|
||||
current_time = time.time()
|
||||
if (
|
||||
self._download_progress_callback
|
||||
and (current_time - last_update_time) >= 0.1
|
||||
):
|
||||
elapsed = current_time - start_time
|
||||
if elapsed > 0:
|
||||
speed_mbps = (downloaded_size / (1024 * 1024)) / elapsed
|
||||
else:
|
||||
speed_mbps = 0
|
||||
|
||||
if total_size > 0:
|
||||
progress = downloaded_size / total_size
|
||||
remaining_mb = (total_size - downloaded_size) / (1024 * 1024)
|
||||
if speed_mbps > 0:
|
||||
eta_seconds = remaining_mb / speed_mbps
|
||||
eta_str = (
|
||||
f"{int(eta_seconds)}s"
|
||||
if eta_seconds < 60
|
||||
else f"{int(eta_seconds / 60)}m {int(eta_seconds % 60)}s"
|
||||
)
|
||||
else:
|
||||
eta_str = "--"
|
||||
status = f"{downloaded_size / (1024 * 1024):.1f} / {total_size / (1024 * 1024):.1f} MB • {speed_mbps:.1f} MB/s • ETA: {eta_str}"
|
||||
else:
|
||||
progress = 0
|
||||
status = (
|
||||
f"{downloaded_size / (1024 * 1024):.1f} MB • {speed_mbps:.1f} MB/s"
|
||||
)
|
||||
|
||||
self._download_progress_callback(progress, speed_mbps, status)
|
||||
last_update_time = current_time
|
||||
|
||||
logger.info(f"Download progress: {progress * 100:.1f}% - {status}")
|
||||
|
||||
# Rename temp file to final
|
||||
os.rename(temp_file, model_file)
|
||||
logger.info("Whisper model downloaded successfully")
|
||||
|
||||
if self._download_progress_callback:
|
||||
self._download_progress_callback(1.0, 0, "Complete!")
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Failed to download Whisper model from {url}: {e}")
|
||||
if os.path.exists(temp_file):
|
||||
os.remove(temp_file)
|
||||
raise RuntimeError(f"Failed to download Whisper model: {e}") from e
|
||||
except Exception as e:
|
||||
logger.error(f"An error occurred during Whisper model download: {e}")
|
||||
if os.path.exists(temp_file):
|
||||
os.remove(temp_file)
|
||||
raise
|
||||
|
||||
def register_text_callback(self, callback: Callable[[str], None]):
|
||||
"""
|
||||
Register a callback function that will be called when text is recognized.
|
||||
@@ -436,12 +641,30 @@ class SpeechRecognitionManager:
|
||||
for callback in self.state_callbacks:
|
||||
callback(new_state)
|
||||
|
||||
@property
|
||||
def model_ready(self) -> bool:
|
||||
"""Check if the model is initialized and ready for recognition."""
|
||||
return self._model_initialized and self.model is not None
|
||||
|
||||
def start_recognition(self):
|
||||
"""Start the speech recognition process."""
|
||||
if self.state != RecognitionState.IDLE:
|
||||
logger.warning(f"Cannot start recognition in current state: {self.state}")
|
||||
return
|
||||
|
||||
# Check if model is ready
|
||||
if not self.model_ready:
|
||||
logger.warning(
|
||||
"Cannot start recognition: model not downloaded. Please download via Settings."
|
||||
)
|
||||
play_error_sound()
|
||||
_show_notification(
|
||||
"No Speech Model",
|
||||
"Please open Settings and download a speech recognition model to use dictation.",
|
||||
"dialog-warning",
|
||||
)
|
||||
return
|
||||
|
||||
logger.info("Starting speech recognition")
|
||||
self._update_state(RecognitionState.LISTENING)
|
||||
|
||||
@@ -500,9 +723,7 @@ class SpeechRecognitionManager:
|
||||
import pyaudio
|
||||
except ImportError as e:
|
||||
logger.error(f"Failed to import required audio libraries: {e}")
|
||||
logger.error(
|
||||
"Please install required dependencies: pip install pyaudio numpy"
|
||||
)
|
||||
logger.error("Please install required dependencies: pip install pyaudio numpy")
|
||||
play_error_sound()
|
||||
self._update_state(RecognitionState.ERROR)
|
||||
return
|
||||
@@ -545,9 +766,7 @@ class SpeechRecognitionManager:
|
||||
# Ensure vad_sensitivity is treated as integer for calculation
|
||||
try:
|
||||
vad_sens = int(self.vad_sensitivity)
|
||||
threshold = 500 / max(
|
||||
1, min(5, vad_sens)
|
||||
) # Use self.vad_sensitivity
|
||||
threshold = 500 / max(1, min(5, vad_sens)) # Use self.vad_sensitivity
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
f"Invalid VAD sensitivity value: {self.vad_sensitivity}. Using default 3."
|
||||
@@ -556,9 +775,7 @@ class SpeechRecognitionManager:
|
||||
|
||||
if volume < threshold: # Silence
|
||||
silence_counter += CHUNK / RATE # Convert chunks to seconds
|
||||
if (
|
||||
silence_counter > self.silence_timeout
|
||||
): # Use self.silence_timeout
|
||||
if silence_counter > self.silence_timeout: # Use self.silence_timeout
|
||||
logger.debug("Silence detected, processing buffer")
|
||||
self._update_state(RecognitionState.PROCESSING)
|
||||
# Process final buffer
|
||||
@@ -629,6 +846,7 @@ class SpeechRecognitionManager:
|
||||
model_size: Optional[str] = None,
|
||||
vad_sensitivity: Optional[int] = None,
|
||||
silence_timeout: Optional[float] = None,
|
||||
force_download: bool = True,
|
||||
**kwargs, # Allow for future expansion
|
||||
):
|
||||
"""
|
||||
@@ -639,6 +857,7 @@ class SpeechRecognitionManager:
|
||||
model_size: The new model size.
|
||||
vad_sensitivity: New VAD sensitivity (for VOSK).
|
||||
silence_timeout: New silence timeout (for VOSK).
|
||||
force_download: If True, download missing models (default: True for UI-triggered reconfigures).
|
||||
"""
|
||||
logger.info(
|
||||
f"Reconfiguring speech engine. New settings: engine={engine}, model_size={model_size}, vad={vad_sensitivity}, silence={silence_timeout}"
|
||||
@@ -661,6 +880,9 @@ class SpeechRecognitionManager:
|
||||
|
||||
if restart_needed:
|
||||
logger.info("Engine or model changed, re-initializing...")
|
||||
# When reconfiguring from UI, allow downloads
|
||||
old_defer = self._defer_download
|
||||
self._defer_download = not force_download
|
||||
# Release old resources explicitly if necessary (Python's GC might handle it)
|
||||
self.model = None
|
||||
self.recognizer = None
|
||||
@@ -670,17 +892,15 @@ class SpeechRecognitionManager:
|
||||
elif self.engine == "whisper":
|
||||
self._init_whisper()
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unsupported engine during reconfigure: {self.engine}"
|
||||
)
|
||||
raise ValueError(f"Unsupported engine during reconfigure: {self.engine}")
|
||||
logger.info("Speech engine re-initialized successfully.")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to re-initialize speech engine: {e}", exc_info=True
|
||||
)
|
||||
logger.error(f"Failed to re-initialize speech engine: {e}", exc_info=True)
|
||||
self._update_state(RecognitionState.ERROR)
|
||||
# Re-raise or handle appropriately
|
||||
raise
|
||||
finally:
|
||||
self._defer_download = old_defer
|
||||
else:
|
||||
# If only VOSK params changed, just log it
|
||||
logger.info("Applied VAD/silence timeout changes.")
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -16,21 +16,21 @@ logger = logging.getLogger(__name__)
|
||||
class ActionHandler:
|
||||
"""
|
||||
Handles special voice command actions.
|
||||
|
||||
|
||||
This class processes action commands from the speech recognition system
|
||||
and performs the appropriate actions like deleting text, undoing, etc.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, text_injector: "TextInjector"):
|
||||
"""
|
||||
Initialize the action handler.
|
||||
|
||||
|
||||
Args:
|
||||
text_injector: The text injector instance for performing actions
|
||||
"""
|
||||
self.text_injector = text_injector
|
||||
self.last_injected_text = ""
|
||||
|
||||
|
||||
# Map actions to handler methods
|
||||
self.action_handlers = {
|
||||
"delete_last": self._handle_delete_last,
|
||||
@@ -44,19 +44,19 @@ class ActionHandler:
|
||||
"copy": self._handle_copy,
|
||||
"paste": self._handle_paste,
|
||||
}
|
||||
|
||||
|
||||
def handle_action(self, action: str) -> bool:
|
||||
"""
|
||||
Handle a voice command action.
|
||||
|
||||
|
||||
Args:
|
||||
action: The action to perform
|
||||
|
||||
|
||||
Returns:
|
||||
True if the action was handled successfully, False otherwise
|
||||
"""
|
||||
logger.debug(f"Handling action: {action}")
|
||||
|
||||
|
||||
handler = self.action_handlers.get(action)
|
||||
if handler:
|
||||
try:
|
||||
@@ -67,64 +67,64 @@ class ActionHandler:
|
||||
else:
|
||||
logger.warning(f"Unknown action: {action}")
|
||||
return False
|
||||
|
||||
|
||||
def set_last_injected_text(self, text: str):
|
||||
"""
|
||||
Set the last injected text for undo/delete operations.
|
||||
|
||||
|
||||
Args:
|
||||
text: The last text that was injected
|
||||
"""
|
||||
self.last_injected_text = text
|
||||
|
||||
|
||||
def _handle_delete_last(self) -> bool:
|
||||
"""Handle 'delete that' command by sending backspace keys."""
|
||||
if not self.last_injected_text:
|
||||
logger.debug("No text to delete")
|
||||
return True
|
||||
|
||||
|
||||
# Send backspace keys for each character in the last injected text
|
||||
backspaces = "\b" * len(self.last_injected_text)
|
||||
success = self.text_injector.inject_text(backspaces)
|
||||
|
||||
|
||||
if success:
|
||||
logger.debug(f"Deleted {len(self.last_injected_text)} characters")
|
||||
self.last_injected_text = ""
|
||||
|
||||
|
||||
return success
|
||||
|
||||
|
||||
def _handle_undo(self) -> bool:
|
||||
"""Handle 'undo' command by sending Ctrl+Z."""
|
||||
return self.text_injector._inject_keyboard_shortcut("ctrl+z")
|
||||
|
||||
|
||||
def _handle_redo(self) -> bool:
|
||||
"""Handle 'redo' command by sending Ctrl+Y."""
|
||||
return self.text_injector._inject_keyboard_shortcut("ctrl+y")
|
||||
|
||||
|
||||
def _handle_select_all(self) -> bool:
|
||||
"""Handle 'select all' command by sending Ctrl+A."""
|
||||
return self.text_injector._inject_keyboard_shortcut("ctrl+a")
|
||||
|
||||
|
||||
def _handle_select_line(self) -> bool:
|
||||
"""Handle 'select line' command by sending Home+Shift+End."""
|
||||
return self.text_injector._inject_keyboard_shortcut("Home+shift+End")
|
||||
|
||||
|
||||
def _handle_select_word(self) -> bool:
|
||||
"""Handle 'select word' command by sending Ctrl+Shift+Right."""
|
||||
return self.text_injector._inject_keyboard_shortcut("ctrl+shift+Right")
|
||||
|
||||
|
||||
def _handle_select_paragraph(self) -> bool:
|
||||
"""Handle 'select paragraph' command by sending Ctrl+Shift+Down."""
|
||||
return self.text_injector._inject_keyboard_shortcut("ctrl+shift+Down")
|
||||
|
||||
|
||||
def _handle_cut(self) -> bool:
|
||||
"""Handle 'cut' command by sending Ctrl+X."""
|
||||
return self.text_injector._inject_keyboard_shortcut("ctrl+x")
|
||||
|
||||
|
||||
def _handle_copy(self) -> bool:
|
||||
"""Handle 'copy' command by sending Ctrl+C."""
|
||||
return self.text_injector._inject_keyboard_shortcut("ctrl+c")
|
||||
|
||||
|
||||
def _handle_paste(self) -> bool:
|
||||
"""Handle 'paste' command by sending Ctrl+V."""
|
||||
return self.text_injector._inject_keyboard_shortcut("ctrl+v")
|
||||
return self.text_injector._inject_keyboard_shortcut("ctrl+v")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -47,9 +47,7 @@ class KeyboardShortcutManager:
|
||||
self.double_tap_threshold = 0.3 # seconds between taps to count as double-tap
|
||||
|
||||
if not KEYBOARD_AVAILABLE:
|
||||
logger.error(
|
||||
"Keyboard shortcut libraries not available. Shortcuts will not work."
|
||||
)
|
||||
logger.error("Keyboard shortcut libraries not available. Shortcuts will not work.")
|
||||
return
|
||||
|
||||
def start(self):
|
||||
@@ -68,9 +66,7 @@ class KeyboardShortcutManager:
|
||||
|
||||
try:
|
||||
# Start keyboard listener in a separate thread
|
||||
self.listener = keyboard.Listener(
|
||||
on_press=self._on_press, on_release=self._on_release
|
||||
)
|
||||
self.listener = keyboard.Listener(on_press=self._on_press, on_release=self._on_release)
|
||||
self.listener.daemon = True
|
||||
self.listener.start()
|
||||
|
||||
@@ -125,16 +121,11 @@ class KeyboardShortcutManager:
|
||||
current_time = time.time()
|
||||
if current_time - self.last_ctrl_press_time < self.double_tap_threshold:
|
||||
# This is a double-tap Ctrl
|
||||
if (
|
||||
self.double_tap_callback
|
||||
and current_time - self.last_trigger_time > 0.5
|
||||
):
|
||||
if self.double_tap_callback and current_time - self.last_trigger_time > 0.5:
|
||||
logger.debug("Double-tap Ctrl detected")
|
||||
self.last_trigger_time = current_time
|
||||
# Run callback in a separate thread to avoid blocking
|
||||
threading.Thread(
|
||||
target=self.double_tap_callback, daemon=True
|
||||
).start()
|
||||
threading.Thread(target=self.double_tap_callback, daemon=True).start()
|
||||
self.last_ctrl_press_time = current_time
|
||||
|
||||
# Add to currently pressed modifier keys (only for tracking Ctrl)
|
||||
|
||||
+116
-116
@@ -23,74 +23,74 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class LoggingDialog(Gtk.Dialog):
|
||||
"""GTK Dialog for viewing and managing application logs."""
|
||||
|
||||
|
||||
def __init__(self, parent: Optional[Gtk.Window] = None):
|
||||
super().__init__(
|
||||
title="VocaLinux Logs",
|
||||
transient_for=parent,
|
||||
flags=Gtk.DialogFlags.DESTROY_WITH_PARENT,
|
||||
modal=False
|
||||
modal=False,
|
||||
)
|
||||
|
||||
|
||||
self.logging_manager = get_logging_manager()
|
||||
self.auto_scroll = True
|
||||
self.filter_level = None
|
||||
self.filter_module = None
|
||||
|
||||
|
||||
# Set dialog properties
|
||||
self.set_default_size(900, 700)
|
||||
self.set_border_width(10)
|
||||
|
||||
|
||||
# Add buttons
|
||||
self.add_button("Close", Gtk.ResponseType.CLOSE)
|
||||
self.add_button("Copy All", Gtk.ResponseType.HELP)
|
||||
self.add_button("Export", Gtk.ResponseType.APPLY)
|
||||
self.add_button("Clear", Gtk.ResponseType.REJECT)
|
||||
|
||||
|
||||
# Create UI
|
||||
self._create_ui()
|
||||
|
||||
|
||||
# Register for new log records
|
||||
self.logging_manager.register_callback(self._on_new_log_record)
|
||||
|
||||
|
||||
# Load existing logs
|
||||
self._refresh_logs()
|
||||
|
||||
|
||||
# Connect signals
|
||||
self.connect("response", self._on_response)
|
||||
self.connect("destroy", self._on_destroy)
|
||||
|
||||
|
||||
def _create_ui(self):
|
||||
"""Create the user interface."""
|
||||
content_area = self.get_content_area()
|
||||
|
||||
|
||||
# Create main container
|
||||
main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
|
||||
content_area.add(main_box)
|
||||
|
||||
|
||||
# Create toolbar
|
||||
toolbar_box = self._create_toolbar()
|
||||
main_box.pack_start(toolbar_box, False, False, 0)
|
||||
|
||||
|
||||
# Create log view (give it most of the space)
|
||||
log_view_box = self._create_log_view()
|
||||
main_box.pack_start(log_view_box, True, True, 0)
|
||||
|
||||
|
||||
# Create status bar
|
||||
status_box = self._create_status_bar()
|
||||
main_box.pack_start(status_box, False, False, 0)
|
||||
|
||||
|
||||
# Show all widgets
|
||||
main_box.show_all()
|
||||
|
||||
|
||||
def _create_toolbar(self):
|
||||
"""Create the toolbar with filters and controls."""
|
||||
toolbar_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
|
||||
|
||||
|
||||
# Level filter
|
||||
level_label = Gtk.Label(label="Level:")
|
||||
toolbar_box.pack_start(level_label, False, False, 0)
|
||||
|
||||
|
||||
self.level_combo = Gtk.ComboBoxText()
|
||||
self.level_combo.append("ALL", "All Levels")
|
||||
self.level_combo.append("DEBUG", "Debug")
|
||||
@@ -101,29 +101,29 @@ class LoggingDialog(Gtk.Dialog):
|
||||
self.level_combo.set_active(0)
|
||||
self.level_combo.connect("changed", self._on_filter_changed)
|
||||
toolbar_box.pack_start(self.level_combo, False, False, 0)
|
||||
|
||||
|
||||
# Module filter
|
||||
module_label = Gtk.Label(label="Module:")
|
||||
toolbar_box.pack_start(module_label, False, False, 0)
|
||||
|
||||
|
||||
self.module_entry = Gtk.Entry()
|
||||
self.module_entry.set_placeholder_text("Filter by module name...")
|
||||
self.module_entry.connect("changed", self._on_filter_changed)
|
||||
toolbar_box.pack_start(self.module_entry, True, True, 0)
|
||||
|
||||
|
||||
# Auto-scroll toggle
|
||||
self.auto_scroll_check = Gtk.CheckButton(label="Auto-scroll")
|
||||
self.auto_scroll_check.set_active(True)
|
||||
self.auto_scroll_check.connect("toggled", self._on_auto_scroll_toggled)
|
||||
toolbar_box.pack_start(self.auto_scroll_check, False, False, 0)
|
||||
|
||||
|
||||
# Refresh button
|
||||
refresh_button = Gtk.Button(label="Refresh")
|
||||
refresh_button.connect("clicked", self._on_refresh_clicked)
|
||||
toolbar_box.pack_start(refresh_button, False, False, 0)
|
||||
|
||||
|
||||
return toolbar_box
|
||||
|
||||
|
||||
def _create_log_view(self):
|
||||
"""Create the main log viewing area."""
|
||||
# Create scrolled window
|
||||
@@ -134,7 +134,7 @@ class LoggingDialog(Gtk.Dialog):
|
||||
scrolled.set_min_content_height(400)
|
||||
scrolled.set_vexpand(True)
|
||||
scrolled.set_hexpand(True)
|
||||
|
||||
|
||||
# Create text view
|
||||
self.text_view = Gtk.TextView()
|
||||
self.text_view.set_editable(False)
|
||||
@@ -143,189 +143,188 @@ class LoggingDialog(Gtk.Dialog):
|
||||
# Ensure the text view expands to fill available space
|
||||
self.text_view.set_vexpand(True)
|
||||
self.text_view.set_hexpand(True)
|
||||
|
||||
|
||||
# Set monospace font
|
||||
font_desc = self.text_view.get_pango_context().get_font_description()
|
||||
font_desc.set_family("monospace")
|
||||
font_desc.set_size(9 * 1024) # 9pt
|
||||
self.text_view.override_font(font_desc)
|
||||
|
||||
|
||||
# Get text buffer
|
||||
self.text_buffer = self.text_view.get_buffer()
|
||||
|
||||
|
||||
# Create text tags for different log levels
|
||||
self._create_text_tags()
|
||||
|
||||
|
||||
scrolled.add(self.text_view)
|
||||
|
||||
|
||||
# Store reference to scrolled window for auto-scrolling
|
||||
self.scrolled_window = scrolled
|
||||
|
||||
|
||||
return scrolled
|
||||
|
||||
|
||||
def _create_text_tags(self):
|
||||
"""Create text tags for different log levels."""
|
||||
# Debug - gray
|
||||
debug_tag = self.text_buffer.create_tag("DEBUG")
|
||||
debug_tag.set_property("foreground", "#666666")
|
||||
|
||||
|
||||
# Info - default color
|
||||
info_tag = self.text_buffer.create_tag("INFO")
|
||||
|
||||
|
||||
# Warning - orange
|
||||
warning_tag = self.text_buffer.create_tag("WARNING")
|
||||
warning_tag.set_property("foreground", "#FF8C00")
|
||||
warning_tag.set_property("weight", 600)
|
||||
|
||||
|
||||
# Error - red
|
||||
error_tag = self.text_buffer.create_tag("ERROR")
|
||||
error_tag.set_property("foreground", "#DC143C")
|
||||
error_tag.set_property("weight", 700)
|
||||
|
||||
|
||||
# Critical - red background
|
||||
critical_tag = self.text_buffer.create_tag("CRITICAL")
|
||||
critical_tag.set_property("foreground", "#FFFFFF")
|
||||
critical_tag.set_property("background", "#DC143C")
|
||||
critical_tag.set_property("weight", 700)
|
||||
|
||||
|
||||
def _create_status_bar(self):
|
||||
"""Create the status bar."""
|
||||
status_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
|
||||
|
||||
|
||||
self.status_label = Gtk.Label()
|
||||
self.status_label.set_halign(Gtk.Align.START)
|
||||
status_box.pack_start(self.status_label, True, True, 0)
|
||||
|
||||
|
||||
# Update status
|
||||
self._update_status()
|
||||
|
||||
|
||||
return status_box
|
||||
|
||||
|
||||
def _update_status(self):
|
||||
"""Update the status bar with current log statistics."""
|
||||
stats = self.logging_manager.get_log_stats()
|
||||
|
||||
if stats['total'] == 0:
|
||||
|
||||
if stats["total"] == 0:
|
||||
status_text = "No log records"
|
||||
else:
|
||||
level_counts = []
|
||||
for level in ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']:
|
||||
count = stats['by_level'].get(level, 0)
|
||||
for level in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]:
|
||||
count = stats["by_level"].get(level, 0)
|
||||
if count > 0:
|
||||
level_counts.append(f"{level}: {count}")
|
||||
|
||||
|
||||
status_text = f"Total: {stats['total']} | " + " | ".join(level_counts)
|
||||
|
||||
|
||||
self.status_label.set_text(status_text)
|
||||
|
||||
|
||||
def _on_filter_changed(self, widget):
|
||||
"""Handle filter changes."""
|
||||
# Get current filter values
|
||||
level_id = self.level_combo.get_active_id()
|
||||
self.filter_level = None if level_id == "ALL" else level_id
|
||||
|
||||
|
||||
module_text = self.module_entry.get_text().strip()
|
||||
self.filter_module = module_text if module_text else None
|
||||
|
||||
|
||||
# Refresh logs with new filters
|
||||
self._refresh_logs()
|
||||
|
||||
|
||||
def _on_auto_scroll_toggled(self, widget):
|
||||
"""Handle auto-scroll toggle."""
|
||||
self.auto_scroll = widget.get_active()
|
||||
|
||||
|
||||
def _on_refresh_clicked(self, widget):
|
||||
"""Handle refresh button click."""
|
||||
self._refresh_logs()
|
||||
|
||||
|
||||
def _refresh_logs(self):
|
||||
"""Refresh the log display with current filters."""
|
||||
# Get filtered logs
|
||||
records = self.logging_manager.get_logs(
|
||||
level_filter=self.filter_level,
|
||||
module_filter=self.filter_module
|
||||
level_filter=self.filter_level, module_filter=self.filter_module
|
||||
)
|
||||
|
||||
|
||||
# Clear text buffer
|
||||
self.text_buffer.set_text("")
|
||||
|
||||
|
||||
# Add log records
|
||||
for record in records:
|
||||
self._append_log_record(record)
|
||||
|
||||
|
||||
# Update status
|
||||
self._update_status()
|
||||
|
||||
|
||||
# Auto-scroll to bottom
|
||||
if self.auto_scroll:
|
||||
self._scroll_to_bottom()
|
||||
|
||||
|
||||
def _append_log_record(self, record: LogRecord):
|
||||
"""
|
||||
Append a log record to the text view.
|
||||
|
||||
|
||||
Args:
|
||||
record: The log record to append
|
||||
"""
|
||||
# Format the log line
|
||||
log_line = str(record) + "\n"
|
||||
|
||||
|
||||
# Get end iterator
|
||||
end_iter = self.text_buffer.get_end_iter()
|
||||
|
||||
|
||||
# Insert text with appropriate tag
|
||||
tag_name = record.level
|
||||
if tag_name in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]:
|
||||
self.text_buffer.insert_with_tags_by_name(end_iter, log_line, tag_name)
|
||||
else:
|
||||
self.text_buffer.insert(end_iter, log_line)
|
||||
|
||||
|
||||
def _scroll_to_bottom(self):
|
||||
"""Scroll the text view to the bottom."""
|
||||
mark = self.text_buffer.get_insert()
|
||||
end_iter = self.text_buffer.get_end_iter()
|
||||
self.text_buffer.place_cursor(end_iter)
|
||||
self.text_view.scroll_to_mark(mark, 0.0, False, 0.0, 0.0)
|
||||
|
||||
|
||||
def _on_new_log_record(self, record: LogRecord):
|
||||
"""
|
||||
Handle new log record from the logging manager.
|
||||
|
||||
|
||||
Args:
|
||||
record: The new log record
|
||||
"""
|
||||
# Check if record matches current filters
|
||||
if self.filter_level and record.level != self.filter_level:
|
||||
return
|
||||
|
||||
|
||||
if self.filter_module and self.filter_module.lower() not in record.logger_name.lower():
|
||||
return
|
||||
|
||||
|
||||
# Add to UI in main thread
|
||||
GLib.idle_add(self._append_log_record_safe, record)
|
||||
|
||||
|
||||
def _append_log_record_safe(self, record: LogRecord):
|
||||
"""
|
||||
Safely append a log record in the main thread.
|
||||
|
||||
|
||||
Args:
|
||||
record: The log record to append
|
||||
"""
|
||||
try:
|
||||
self._append_log_record(record)
|
||||
|
||||
|
||||
# Auto-scroll if enabled
|
||||
if self.auto_scroll:
|
||||
self._scroll_to_bottom()
|
||||
|
||||
|
||||
# Update status
|
||||
self._update_status()
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error appending log record: {e}")
|
||||
|
||||
|
||||
return False # Remove from idle queue
|
||||
|
||||
|
||||
def _on_response(self, dialog, response_id):
|
||||
"""Handle dialog responses."""
|
||||
if response_id == Gtk.ResponseType.APPLY:
|
||||
@@ -336,49 +335,48 @@ class LoggingDialog(Gtk.Dialog):
|
||||
self._copy_logs_to_clipboard()
|
||||
elif response_id == Gtk.ResponseType.CLOSE:
|
||||
self.destroy()
|
||||
|
||||
|
||||
def _export_logs(self):
|
||||
"""Export logs to a file."""
|
||||
# Create file chooser dialog
|
||||
file_dialog = Gtk.FileChooserDialog(
|
||||
title="Export Logs",
|
||||
parent=self,
|
||||
action=Gtk.FileChooserAction.SAVE
|
||||
title="Export Logs", parent=self, action=Gtk.FileChooserAction.SAVE
|
||||
)
|
||||
|
||||
|
||||
file_dialog.add_buttons(
|
||||
Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
|
||||
Gtk.STOCK_SAVE, Gtk.ResponseType.OK
|
||||
Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_SAVE, Gtk.ResponseType.OK
|
||||
)
|
||||
|
||||
|
||||
# Set default filename
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
default_filename = f"vocalinux_logs_{timestamp}.txt"
|
||||
file_dialog.set_current_name(default_filename)
|
||||
|
||||
|
||||
# Add file filter
|
||||
file_filter = Gtk.FileFilter()
|
||||
file_filter.set_name("Text files")
|
||||
file_filter.add_pattern("*.txt")
|
||||
file_dialog.add_filter(file_filter)
|
||||
|
||||
|
||||
response = file_dialog.run()
|
||||
|
||||
|
||||
if response == Gtk.ResponseType.OK:
|
||||
filepath = file_dialog.get_filename()
|
||||
success = self.logging_manager.export_logs(
|
||||
filepath,
|
||||
level_filter=self.filter_level,
|
||||
module_filter=self.filter_module
|
||||
filepath, level_filter=self.filter_level, module_filter=self.filter_module
|
||||
)
|
||||
|
||||
|
||||
if success:
|
||||
self._show_message("Export successful", f"Logs exported to:\n{filepath}")
|
||||
else:
|
||||
self._show_message("Export failed", "Failed to export logs. Check the logs for details.", Gtk.MessageType.ERROR)
|
||||
|
||||
self._show_message(
|
||||
"Export failed",
|
||||
"Failed to export logs. Check the logs for details.",
|
||||
Gtk.MessageType.ERROR,
|
||||
)
|
||||
|
||||
file_dialog.destroy()
|
||||
|
||||
|
||||
def _copy_logs_to_clipboard(self):
|
||||
"""Copy all visible logs to clipboard."""
|
||||
try:
|
||||
@@ -386,43 +384,43 @@ class LoggingDialog(Gtk.Dialog):
|
||||
start_iter = self.text_buffer.get_start_iter()
|
||||
end_iter = self.text_buffer.get_end_iter()
|
||||
text_content = self.text_buffer.get_text(start_iter, end_iter, False)
|
||||
|
||||
|
||||
if not text_content.strip():
|
||||
self._show_message("No logs to copy", "There are no logs to copy to clipboard.")
|
||||
return
|
||||
|
||||
|
||||
# Get the clipboard
|
||||
from gi.repository import Gdk
|
||||
|
||||
clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
|
||||
|
||||
|
||||
# Create header for clipboard content
|
||||
from datetime import datetime
|
||||
|
||||
header = f"VocaLinux Logs - Copied at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
|
||||
header += "=" * 80 + "\n\n"
|
||||
|
||||
|
||||
# Combine header and content
|
||||
clipboard_content = header + text_content
|
||||
|
||||
|
||||
# Set clipboard content
|
||||
clipboard.set_text(clipboard_content, -1)
|
||||
clipboard.store()
|
||||
|
||||
|
||||
# Count lines for user feedback
|
||||
line_count = len(text_content.strip().split('\n'))
|
||||
line_count = len(text_content.strip().split("\n"))
|
||||
self._show_message(
|
||||
"Logs copied to clipboard",
|
||||
"Logs copied to clipboard",
|
||||
f"Successfully copied {line_count} log lines to clipboard.\n\n"
|
||||
"You can now paste them into any text editor or document."
|
||||
"You can now paste them into any text editor or document.",
|
||||
)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to copy logs to clipboard: {e}")
|
||||
self._show_message(
|
||||
"Copy failed",
|
||||
f"Failed to copy logs to clipboard: {e}",
|
||||
Gtk.MessageType.ERROR
|
||||
"Copy failed", f"Failed to copy logs to clipboard: {e}", Gtk.MessageType.ERROR
|
||||
)
|
||||
|
||||
|
||||
def _clear_logs(self):
|
||||
"""Clear all logs after confirmation."""
|
||||
# Show confirmation dialog
|
||||
@@ -431,25 +429,27 @@ class LoggingDialog(Gtk.Dialog):
|
||||
flags=0,
|
||||
message_type=Gtk.MessageType.QUESTION,
|
||||
buttons=Gtk.ButtonsType.YES_NO,
|
||||
text="Clear all logs?"
|
||||
text="Clear all logs?",
|
||||
)
|
||||
confirm_dialog.format_secondary_text(
|
||||
"This will permanently remove all log records from memory. "
|
||||
"This action cannot be undone."
|
||||
)
|
||||
|
||||
|
||||
response = confirm_dialog.run()
|
||||
confirm_dialog.destroy()
|
||||
|
||||
|
||||
if response == Gtk.ResponseType.YES:
|
||||
self.logging_manager.clear_logs()
|
||||
self._refresh_logs()
|
||||
self._show_message("Logs cleared", "All log records have been cleared.")
|
||||
|
||||
def _show_message(self, title: str, message: str, message_type: Gtk.MessageType = Gtk.MessageType.INFO):
|
||||
|
||||
def _show_message(
|
||||
self, title: str, message: str, message_type: Gtk.MessageType = Gtk.MessageType.INFO
|
||||
):
|
||||
"""
|
||||
Show a message dialog.
|
||||
|
||||
|
||||
Args:
|
||||
title: Dialog title
|
||||
message: Message text
|
||||
@@ -460,13 +460,13 @@ class LoggingDialog(Gtk.Dialog):
|
||||
flags=0,
|
||||
message_type=message_type,
|
||||
buttons=Gtk.ButtonsType.OK,
|
||||
text=title
|
||||
text=title,
|
||||
)
|
||||
dialog.format_secondary_text(message)
|
||||
dialog.run()
|
||||
dialog.destroy()
|
||||
|
||||
|
||||
def _on_destroy(self, widget):
|
||||
"""Handle dialog destruction."""
|
||||
# Unregister callback
|
||||
self.logging_manager.unregister_callback(self._on_new_log_record)
|
||||
self.logging_manager.unregister_callback(self._on_new_log_record)
|
||||
|
||||
@@ -18,24 +18,26 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class LogRecord:
|
||||
"""Represents a single log record with additional metadata."""
|
||||
|
||||
def __init__(self, timestamp: datetime, level: str, logger_name: str, message: str, module: str = ""):
|
||||
|
||||
def __init__(
|
||||
self, timestamp: datetime, level: str, logger_name: str, message: str, module: str = ""
|
||||
):
|
||||
self.timestamp = timestamp
|
||||
self.level = level
|
||||
self.logger_name = logger_name
|
||||
self.message = message
|
||||
self.module = module
|
||||
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary for easy serialization."""
|
||||
return {
|
||||
'timestamp': self.timestamp.isoformat(),
|
||||
'level': self.level,
|
||||
'logger_name': self.logger_name,
|
||||
'message': self.message,
|
||||
'module': self.module
|
||||
"timestamp": self.timestamp.isoformat(),
|
||||
"level": self.level,
|
||||
"logger_name": self.logger_name,
|
||||
"message": self.message,
|
||||
"module": self.module,
|
||||
}
|
||||
|
||||
|
||||
def __str__(self):
|
||||
"""String representation for display."""
|
||||
time_str = self.timestamp.strftime("%H:%M:%S.%f")[:-3] # Include milliseconds
|
||||
@@ -45,15 +47,15 @@ class LogRecord:
|
||||
class LoggingManager:
|
||||
"""
|
||||
Centralized logging manager for VocaLinux.
|
||||
|
||||
|
||||
This class captures all application logs, stores them in memory,
|
||||
and provides functionality for viewing, filtering, and exporting logs.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, max_records: int = 1000):
|
||||
"""
|
||||
Initialize the logging manager.
|
||||
|
||||
|
||||
Args:
|
||||
max_records: Maximum number of log records to keep in memory
|
||||
"""
|
||||
@@ -61,41 +63,39 @@ class LoggingManager:
|
||||
self.log_records: List[LogRecord] = []
|
||||
self.log_callbacks: List[Callable[[LogRecord], None]] = []
|
||||
self.lock = threading.Lock()
|
||||
|
||||
|
||||
# Create logs directory
|
||||
self.logs_dir = Path.home() / ".local" / "share" / "vocalinux" / "logs"
|
||||
self.logs_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
# Set up custom log handler
|
||||
self.handler = LoggingHandler(self)
|
||||
self.handler.setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
# Create formatter
|
||||
formatter = logging.Formatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
||||
self.handler.setFormatter(formatter)
|
||||
|
||||
|
||||
# Add handler to root logger
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.addHandler(self.handler)
|
||||
|
||||
|
||||
logger.info("Logging manager initialized")
|
||||
|
||||
|
||||
def add_log_record(self, record: LogRecord):
|
||||
"""
|
||||
Add a new log record.
|
||||
|
||||
|
||||
Args:
|
||||
record: The log record to add
|
||||
"""
|
||||
with self.lock:
|
||||
self.log_records.append(record)
|
||||
|
||||
|
||||
# Trim old records if we exceed max_records
|
||||
if len(self.log_records) > self.max_records:
|
||||
self.log_records = self.log_records[-self.max_records:]
|
||||
|
||||
self.log_records = self.log_records[-self.max_records :]
|
||||
|
||||
# Notify callbacks
|
||||
for callback in self.log_callbacks:
|
||||
try:
|
||||
@@ -103,21 +103,21 @@ class LoggingManager:
|
||||
except Exception as e:
|
||||
# Don't let callback errors break logging
|
||||
print(f"Error in log callback: {e}")
|
||||
|
||||
|
||||
def register_callback(self, callback: Callable[[LogRecord], None]):
|
||||
"""
|
||||
Register a callback to be called when new log records are added.
|
||||
|
||||
|
||||
Args:
|
||||
callback: Function to call with new log records
|
||||
"""
|
||||
with self.lock:
|
||||
self.log_callbacks.append(callback)
|
||||
|
||||
|
||||
def unregister_callback(self, callback: Callable[[LogRecord], None]):
|
||||
"""
|
||||
Unregister a log callback.
|
||||
|
||||
|
||||
Args:
|
||||
callback: The callback to remove
|
||||
"""
|
||||
@@ -126,54 +126,58 @@ class LoggingManager:
|
||||
self.log_callbacks.remove(callback)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def get_logs(self, level_filter: Optional[str] = None,
|
||||
module_filter: Optional[str] = None,
|
||||
last_n: Optional[int] = None) -> List[LogRecord]:
|
||||
|
||||
def get_logs(
|
||||
self,
|
||||
level_filter: Optional[str] = None,
|
||||
module_filter: Optional[str] = None,
|
||||
last_n: Optional[int] = None,
|
||||
) -> List[LogRecord]:
|
||||
"""
|
||||
Get log records with optional filtering.
|
||||
|
||||
|
||||
Args:
|
||||
level_filter: Filter by log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
module_filter: Filter by module name (partial match)
|
||||
last_n: Return only the last N records
|
||||
|
||||
|
||||
Returns:
|
||||
List of filtered log records
|
||||
"""
|
||||
with self.lock:
|
||||
records = self.log_records.copy()
|
||||
|
||||
|
||||
# Apply filters
|
||||
if level_filter:
|
||||
records = [r for r in records if r.level == level_filter]
|
||||
|
||||
|
||||
if module_filter:
|
||||
records = [r for r in records if module_filter.lower() in r.logger_name.lower()]
|
||||
|
||||
|
||||
# Return last N records
|
||||
if last_n:
|
||||
records = records[-last_n:]
|
||||
|
||||
|
||||
return records
|
||||
|
||||
def export_logs(self, filepath: str, level_filter: Optional[str] = None,
|
||||
module_filter: Optional[str] = None) -> bool:
|
||||
|
||||
def export_logs(
|
||||
self, filepath: str, level_filter: Optional[str] = None, module_filter: Optional[str] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Export logs to a file.
|
||||
|
||||
|
||||
Args:
|
||||
filepath: Path to save the log file
|
||||
level_filter: Filter by log level
|
||||
module_filter: Filter by module name
|
||||
|
||||
|
||||
Returns:
|
||||
True if export was successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
records = self.get_logs(level_filter, module_filter)
|
||||
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
f.write(f"VocaLinux Log Export\n")
|
||||
f.write(f"Generated: {datetime.now().isoformat()}\n")
|
||||
f.write(f"Total Records: {len(records)}\n")
|
||||
@@ -182,73 +186,71 @@ class LoggingManager:
|
||||
if module_filter:
|
||||
f.write(f"Module Filter: {module_filter}\n")
|
||||
f.write("=" * 80 + "\n\n")
|
||||
|
||||
|
||||
for record in records:
|
||||
f.write(str(record) + "\n")
|
||||
|
||||
|
||||
logger.info(f"Exported {len(records)} log records to {filepath}")
|
||||
return True
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to export logs: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def clear_logs(self):
|
||||
"""Clear all stored log records."""
|
||||
with self.lock:
|
||||
self.log_records.clear()
|
||||
logger.info("Log records cleared")
|
||||
|
||||
|
||||
def get_log_stats(self) -> dict:
|
||||
"""
|
||||
Get statistics about the current logs.
|
||||
|
||||
|
||||
Returns:
|
||||
Dictionary with log statistics
|
||||
"""
|
||||
with self.lock:
|
||||
records = self.log_records.copy()
|
||||
|
||||
|
||||
if not records:
|
||||
return {
|
||||
'total': 0,
|
||||
'by_level': {},
|
||||
'by_module': {},
|
||||
'oldest': None,
|
||||
'newest': None
|
||||
}
|
||||
|
||||
return {"total": 0, "by_level": {}, "by_module": {}, "oldest": None, "newest": None}
|
||||
|
||||
# Count by level
|
||||
by_level = {}
|
||||
for record in records:
|
||||
by_level[record.level] = by_level.get(record.level, 0) + 1
|
||||
|
||||
|
||||
# Count by module
|
||||
by_module = {}
|
||||
for record in records:
|
||||
module = record.logger_name.split('.')[0] if '.' in record.logger_name else record.logger_name
|
||||
module = (
|
||||
record.logger_name.split(".")[0]
|
||||
if "." in record.logger_name
|
||||
else record.logger_name
|
||||
)
|
||||
by_module[module] = by_module.get(module, 0) + 1
|
||||
|
||||
|
||||
return {
|
||||
'total': len(records),
|
||||
'by_level': by_level,
|
||||
'by_module': by_module,
|
||||
'oldest': records[0].timestamp if records else None,
|
||||
'newest': records[-1].timestamp if records else None
|
||||
"total": len(records),
|
||||
"by_level": by_level,
|
||||
"by_module": by_module,
|
||||
"oldest": records[0].timestamp if records else None,
|
||||
"newest": records[-1].timestamp if records else None,
|
||||
}
|
||||
|
||||
|
||||
class LoggingHandler(logging.Handler):
|
||||
"""Custom logging handler that feeds records to the LoggingManager."""
|
||||
|
||||
|
||||
def __init__(self, logging_manager: LoggingManager):
|
||||
super().__init__()
|
||||
self.logging_manager = logging_manager
|
||||
|
||||
|
||||
def emit(self, record: logging.LogRecord):
|
||||
"""
|
||||
Emit a log record to the logging manager.
|
||||
|
||||
|
||||
Args:
|
||||
record: The logging record to emit
|
||||
"""
|
||||
@@ -259,12 +261,12 @@ class LoggingHandler(logging.Handler):
|
||||
level=record.levelname,
|
||||
logger_name=record.name,
|
||||
message=record.getMessage(),
|
||||
module=getattr(record, 'module', record.name.split('.')[0])
|
||||
module=getattr(record, "module", record.name.split(".")[0]),
|
||||
)
|
||||
|
||||
|
||||
# Add to logging manager
|
||||
self.logging_manager.add_log_record(log_record)
|
||||
|
||||
|
||||
except Exception:
|
||||
# Don't let logging errors break the application
|
||||
self.handleError(record)
|
||||
@@ -277,7 +279,7 @@ _logging_manager: Optional[LoggingManager] = None
|
||||
def get_logging_manager() -> LoggingManager:
|
||||
"""
|
||||
Get the global logging manager instance.
|
||||
|
||||
|
||||
Returns:
|
||||
The global LoggingManager instance
|
||||
"""
|
||||
@@ -292,4 +294,4 @@ def initialize_logging():
|
||||
global _logging_manager
|
||||
if _logging_manager is None:
|
||||
_logging_manager = LoggingManager()
|
||||
logger.info("Global logging manager initialized")
|
||||
logger.info("Global logging manager initialized")
|
||||
|
||||
+268
-153
@@ -120,9 +120,7 @@ def _get_recommended_whisper_model() -> tuple:
|
||||
|
||||
if torch.cuda.is_available():
|
||||
has_cuda = True
|
||||
cuda_memory_gb = torch.cuda.get_device_properties(
|
||||
0
|
||||
).total_memory // (1024**3)
|
||||
cuda_memory_gb = torch.cuda.get_device_properties(0).total_memory // (1024**3)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -178,32 +176,33 @@ def _get_recommended_vosk_model() -> tuple:
|
||||
|
||||
|
||||
class ModelDownloadDialog(Gtk.Dialog):
|
||||
"""Dialog showing model download progress."""
|
||||
"""Dialog showing model download progress with cancel support."""
|
||||
|
||||
def __init__(
|
||||
self, parent, model_name: str, model_size_mb: int, engine: str = "whisper"
|
||||
):
|
||||
def __init__(self, parent, model_name: str, model_size_mb: int, engine: str = "whisper"):
|
||||
super().__init__(
|
||||
title=f"Downloading {model_name.capitalize()} Model",
|
||||
transient_for=parent,
|
||||
flags=Gtk.DialogFlags.MODAL,
|
||||
)
|
||||
self.set_default_size(400, 150)
|
||||
self.set_default_size(450, 180)
|
||||
self.set_deletable(False) # Prevent closing during download
|
||||
|
||||
self.cancelled = False
|
||||
self.engine = engine
|
||||
self.model_name = model_name
|
||||
|
||||
engine_display = engine.upper() if engine == "vosk" else engine.capitalize()
|
||||
|
||||
box = self.get_content_area()
|
||||
box.set_spacing(15)
|
||||
box.set_spacing(12)
|
||||
box.set_margin_start(20)
|
||||
box.set_margin_end(20)
|
||||
box.set_margin_top(20)
|
||||
box.set_margin_bottom(20)
|
||||
box.set_margin_bottom(15)
|
||||
|
||||
# Info label
|
||||
self.info_label = Gtk.Label(
|
||||
label=f"Downloading {engine_display} {model_name} model (~{_format_size(model_size_mb)})...\n"
|
||||
f"This may take a few minutes depending on your connection.",
|
||||
label=f"Downloading {engine_display} {model_name} model (~{_format_size(model_size_mb)})...",
|
||||
wrap=True,
|
||||
justify=Gtk.Justification.CENTER,
|
||||
)
|
||||
@@ -212,35 +211,77 @@ class ModelDownloadDialog(Gtk.Dialog):
|
||||
# Progress bar
|
||||
self.progress_bar = Gtk.ProgressBar()
|
||||
self.progress_bar.set_show_text(True)
|
||||
self.progress_bar.set_text("Preparing download...")
|
||||
box.pack_start(self.progress_bar, False, False, 0)
|
||||
self.progress_bar.set_text("Connecting...")
|
||||
box.pack_start(self.progress_bar, False, False, 5)
|
||||
|
||||
# Status label
|
||||
# Status label (shows speed and ETA)
|
||||
self.status_label = Gtk.Label(label="")
|
||||
self.status_label.set_markup("<i>Please wait...</i>")
|
||||
box.pack_start(self.status_label, False, False, 0)
|
||||
|
||||
# Cancel button
|
||||
self.cancel_button = Gtk.Button(label="Cancel")
|
||||
self.cancel_button.connect("clicked", self._on_cancel_clicked)
|
||||
self.cancel_button.set_halign(Gtk.Align.CENTER)
|
||||
self.cancel_button.set_margin_top(10)
|
||||
box.pack_start(self.cancel_button, False, False, 0)
|
||||
|
||||
self.show_all()
|
||||
self._pulse_timeout = GLib.timeout_add(100, self._pulse_progress)
|
||||
|
||||
# For Whisper, we can't track progress, so pulse
|
||||
if engine == "whisper":
|
||||
self._pulse_timeout = GLib.timeout_add(100, self._pulse_progress)
|
||||
else:
|
||||
self._pulse_timeout = None
|
||||
|
||||
def _pulse_progress(self):
|
||||
"""Pulse the progress bar while downloading."""
|
||||
"""Pulse the progress bar while downloading (for Whisper)."""
|
||||
if self.cancelled:
|
||||
return False
|
||||
self.progress_bar.pulse()
|
||||
return True # Continue pulsing
|
||||
|
||||
def _on_cancel_clicked(self, widget):
|
||||
"""Handle cancel button click."""
|
||||
self.cancelled = True
|
||||
self.cancel_button.set_sensitive(False)
|
||||
self.cancel_button.set_label("Cancelling...")
|
||||
self.status_label.set_markup("<i>Cancelling download...</i>")
|
||||
|
||||
def update_progress(self, fraction: float, speed_mbps: float, status_text: str):
|
||||
"""Update the progress bar with actual download progress."""
|
||||
if self.cancelled:
|
||||
return
|
||||
|
||||
# Stop pulsing if we were pulsing
|
||||
if self._pulse_timeout:
|
||||
GLib.source_remove(self._pulse_timeout)
|
||||
self._pulse_timeout = None
|
||||
|
||||
self.progress_bar.set_fraction(fraction)
|
||||
self.progress_bar.set_text(f"{fraction * 100:.0f}%")
|
||||
self.status_label.set_markup(f"<i>{status_text}</i>")
|
||||
|
||||
def set_complete(self, success: bool, message: str = ""):
|
||||
"""Mark download as complete."""
|
||||
if hasattr(self, "_pulse_timeout"):
|
||||
if self._pulse_timeout:
|
||||
GLib.source_remove(self._pulse_timeout)
|
||||
self._pulse_timeout = None
|
||||
|
||||
# Hide cancel button
|
||||
self.cancel_button.hide()
|
||||
|
||||
if success:
|
||||
self.progress_bar.set_fraction(1.0)
|
||||
self.progress_bar.set_text("Complete!")
|
||||
self.status_label.set_markup(f"<b>✓ Model ready to use</b>")
|
||||
self.status_label.set_markup("<b>✓ Model ready to use</b>")
|
||||
else:
|
||||
self.progress_bar.set_fraction(0)
|
||||
self.progress_bar.set_text("Failed")
|
||||
self.status_label.set_markup(f"<span color='red'>✗ {message}</span>")
|
||||
if "cancelled" in message.lower():
|
||||
self.status_label.set_markup("<span color='orange'>✗ Download cancelled</span>")
|
||||
else:
|
||||
self.status_label.set_markup(f"<span color='red'>✗ {message}</span>")
|
||||
|
||||
# Allow closing now
|
||||
self.set_deletable(True)
|
||||
@@ -275,15 +316,11 @@ class SettingsDialog(Gtk.Dialog):
|
||||
self.get_content_area().add(self.grid)
|
||||
|
||||
# Engine Selection
|
||||
self.grid.attach(
|
||||
Gtk.Label(label="Speech Engine:", halign=Gtk.Align.START), 0, 0, 1, 1
|
||||
)
|
||||
self.grid.attach(Gtk.Label(label="Speech Engine:", halign=Gtk.Align.START), 0, 0, 1, 1)
|
||||
self.engine_combo = Gtk.ComboBoxText()
|
||||
|
||||
# Model Size Selection
|
||||
self.grid.attach(
|
||||
Gtk.Label(label="Model Size:", halign=Gtk.Align.START), 0, 1, 1, 1
|
||||
)
|
||||
self.grid.attach(Gtk.Label(label="Model Size:", halign=Gtk.Align.START), 0, 1, 1, 1)
|
||||
self.model_combo = Gtk.ComboBoxText()
|
||||
self.grid.attach(self.model_combo, 1, 1, 1, 1)
|
||||
|
||||
@@ -295,55 +332,57 @@ class SettingsDialog(Gtk.Dialog):
|
||||
)
|
||||
self.grid.attach(model_legend, 0, 2, 2, 1)
|
||||
|
||||
# VOSK Specific Settings Box (initially hidden)
|
||||
self.vosk_settings_box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL, spacing=10
|
||||
)
|
||||
self.vosk_grid = Gtk.Grid(column_spacing=10, row_spacing=10)
|
||||
self.vosk_settings_box.pack_start(
|
||||
# Recognition Settings Box (shared between engines)
|
||||
self.recognition_settings_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
|
||||
self.recognition_grid = Gtk.Grid(column_spacing=10, row_spacing=10)
|
||||
self.recognition_settings_box.pack_start(
|
||||
Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), False, False, 5
|
||||
)
|
||||
self.vosk_settings_box.pack_start(
|
||||
Gtk.Label(
|
||||
label="<b>VOSK Settings</b>", use_markup=True, halign=Gtk.Align.START
|
||||
),
|
||||
self.recognition_settings_box.pack_start(
|
||||
Gtk.Label(label="<b>Recognition Settings</b>", use_markup=True, halign=Gtk.Align.START),
|
||||
False,
|
||||
False,
|
||||
5,
|
||||
)
|
||||
self.vosk_settings_box.pack_start(self.vosk_grid, False, False, 0)
|
||||
self.grid.attach(self.vosk_settings_box, 0, 3, 2, 1)
|
||||
self.recognition_settings_box.pack_start(self.recognition_grid, False, False, 0)
|
||||
self.grid.attach(self.recognition_settings_box, 0, 3, 2, 1)
|
||||
|
||||
# VAD Sensitivity
|
||||
self.vosk_grid.attach(Gtk.Label(label="VAD Sensitivity (1-5):"), 0, 0, 1, 1)
|
||||
# VAD Sensitivity (controls how sensitive the mic is to speech vs silence)
|
||||
self.recognition_grid.attach(
|
||||
Gtk.Label(label="VAD Sensitivity (1-5):", halign=Gtk.Align.START), 0, 0, 1, 1
|
||||
)
|
||||
self.vad_spin = Gtk.SpinButton.new_with_range(1, 5, 1)
|
||||
self.vad_spin.set_tooltip_text("Higher = more sensitive to quiet speech")
|
||||
self.vad_spin.connect("value-changed", self._on_vad_changed)
|
||||
self.vosk_grid.attach(self.vad_spin, 1, 0, 1, 1)
|
||||
self.recognition_grid.attach(self.vad_spin, 1, 0, 1, 1)
|
||||
|
||||
# Silence Timeout
|
||||
self.vosk_grid.attach(Gtk.Label(label="Silence Timeout (sec):"), 0, 1, 1, 1)
|
||||
# Silence Timeout (how long to wait before processing)
|
||||
self.recognition_grid.attach(
|
||||
Gtk.Label(label="Silence Timeout (sec):", halign=Gtk.Align.START), 0, 1, 1, 1
|
||||
)
|
||||
self.silence_spin = Gtk.SpinButton.new_with_range(0.5, 5.0, 0.1)
|
||||
self.silence_spin.set_digits(1)
|
||||
self.silence_spin.set_tooltip_text("Wait time after silence before processing speech")
|
||||
self.silence_spin.connect("value-changed", self._on_silence_changed)
|
||||
self.vosk_grid.attach(self.silence_spin, 1, 1, 1, 1)
|
||||
self.recognition_grid.attach(self.silence_spin, 1, 1, 1, 1)
|
||||
|
||||
# VOSK Model info label
|
||||
# VOSK Model info label (shown only for VOSK)
|
||||
self.vosk_model_info_label = Gtk.Label(
|
||||
label="",
|
||||
use_markup=True,
|
||||
halign=Gtk.Align.START,
|
||||
wrap=True,
|
||||
)
|
||||
self.vosk_grid.attach(self.vosk_model_info_label, 0, 2, 2, 1)
|
||||
self.recognition_grid.attach(self.vosk_model_info_label, 0, 2, 2, 1)
|
||||
|
||||
# VOSK Recommendation label
|
||||
# VOSK Recommendation label (shown only for VOSK)
|
||||
self.vosk_recommendation_label = Gtk.Label(
|
||||
label="",
|
||||
use_markup=True,
|
||||
halign=Gtk.Align.START,
|
||||
wrap=True,
|
||||
)
|
||||
self.vosk_grid.attach(self.vosk_recommendation_label, 0, 3, 2, 1)
|
||||
self.recognition_grid.attach(self.vosk_recommendation_label, 0, 3, 2, 1)
|
||||
|
||||
# Whisper Info Box (initially hidden)
|
||||
self.whisper_info_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5)
|
||||
@@ -377,23 +416,19 @@ class SettingsDialog(Gtk.Dialog):
|
||||
halign=Gtk.Align.START,
|
||||
wrap=True,
|
||||
)
|
||||
self.whisper_info_box.pack_start(
|
||||
self.whisper_recommendation_label, False, False, 5
|
||||
)
|
||||
self.whisper_info_box.pack_start(self.whisper_recommendation_label, False, False, 5)
|
||||
|
||||
self.grid.attach(self.whisper_info_box, 0, 3, 2, 1)
|
||||
self.grid.attach(self.whisper_info_box, 0, 4, 2, 1)
|
||||
|
||||
# Add model change handler
|
||||
self.model_combo.connect("changed", self._on_model_changed)
|
||||
|
||||
# Test Area
|
||||
self.grid.attach(
|
||||
Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), 0, 5, 2, 1
|
||||
)
|
||||
self.grid.attach(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), 0, 6, 2, 1)
|
||||
test_label = Gtk.Label(
|
||||
label="<b>Test Recognition</b>", use_markup=True, halign=Gtk.Align.START
|
||||
)
|
||||
self.grid.attach(test_label, 0, 6, 2, 1)
|
||||
self.grid.attach(test_label, 0, 7, 2, 1)
|
||||
|
||||
scrolled_window = Gtk.ScrolledWindow(hexpand=True, vexpand=True)
|
||||
scrolled_window.set_min_content_height(100)
|
||||
@@ -402,11 +437,11 @@ class SettingsDialog(Gtk.Dialog):
|
||||
)
|
||||
self.test_buffer = self.test_textview.get_buffer()
|
||||
scrolled_window.add(self.test_textview)
|
||||
self.grid.attach(scrolled_window, 0, 7, 2, 1)
|
||||
self.grid.attach(scrolled_window, 0, 8, 2, 1)
|
||||
|
||||
self.test_button = Gtk.Button(label="Start Test (3 seconds)")
|
||||
self.test_button.connect("clicked", self._on_test_clicked)
|
||||
self.grid.attach(self.test_button, 0, 8, 2, 1)
|
||||
self.grid.attach(self.test_button, 0, 9, 2, 1)
|
||||
|
||||
# ---- CRITICAL CHANGE ----
|
||||
# Load settings FIRST before creating UI connections
|
||||
@@ -502,6 +537,10 @@ class SettingsDialog(Gtk.Dialog):
|
||||
elif engine == "vosk":
|
||||
recommended_model, _ = _get_recommended_vosk_model()
|
||||
|
||||
# Track which models are downloaded and find smallest downloaded
|
||||
downloaded_models = []
|
||||
smallest_model = None
|
||||
|
||||
# Add model sizes for this engine
|
||||
if engine in ENGINE_MODELS:
|
||||
# Add all options for this engine
|
||||
@@ -511,32 +550,54 @@ class SettingsDialog(Gtk.Dialog):
|
||||
is_downloaded = _is_whisper_model_downloaded(size)
|
||||
status = "✓" if is_downloaded else "↓"
|
||||
rec = " ★" if size == recommended_model else ""
|
||||
display_text = f"{size.capitalize()} ({_format_size(info['size_mb'])}) {status}{rec}"
|
||||
display_text = (
|
||||
f"{size.capitalize()} ({_format_size(info['size_mb'])}) {status}{rec}"
|
||||
)
|
||||
if is_downloaded:
|
||||
downloaded_models.append(size)
|
||||
if smallest_model is None:
|
||||
smallest_model = size
|
||||
elif engine == "vosk" and size in VOSK_MODEL_INFO:
|
||||
info = VOSK_MODEL_INFO[size]
|
||||
is_downloaded = _is_vosk_model_downloaded(size)
|
||||
status = "✓" if is_downloaded else "↓"
|
||||
rec = " ★" if size == recommended_model else ""
|
||||
display_text = f"{size.capitalize()} ({_format_size(info['size_mb'])}) {status}{rec}"
|
||||
display_text = (
|
||||
f"{size.capitalize()} ({_format_size(info['size_mb'])}) {status}{rec}"
|
||||
)
|
||||
if is_downloaded:
|
||||
downloaded_models.append(size)
|
||||
if smallest_model is None:
|
||||
smallest_model = size
|
||||
else:
|
||||
display_text = size.capitalize()
|
||||
# Use lowercase as ID, display text with info
|
||||
self.model_combo.append(size.capitalize(), display_text)
|
||||
|
||||
# Set the active model from settings
|
||||
model_to_set = self.current_model_size.capitalize()
|
||||
logger.info(f"Setting active model to: {model_to_set}")
|
||||
# Determine which model to select:
|
||||
# 1. If saved model is downloaded, use it
|
||||
# 2. Else if any model is downloaded, use the first (smallest) downloaded
|
||||
# 3. Else use the smallest model (but don't auto-download)
|
||||
saved_model = self.current_model_size.lower()
|
||||
if saved_model in downloaded_models:
|
||||
model_to_set = saved_model.capitalize()
|
||||
elif downloaded_models:
|
||||
model_to_set = downloaded_models[0].capitalize()
|
||||
else:
|
||||
model_to_set = smallest_model.capitalize() if smallest_model else "Small"
|
||||
|
||||
logger.info(
|
||||
f"Setting active model to: {model_to_set} (saved={saved_model}, downloaded={downloaded_models})"
|
||||
)
|
||||
|
||||
# Try to set by ID
|
||||
if not self.model_combo.set_active_id(model_to_set):
|
||||
logger.warning(
|
||||
f"Could not set model by ID '{model_to_set}', trying by text"
|
||||
)
|
||||
logger.warning(f"Could not set model by ID '{model_to_set}', trying by text")
|
||||
# Find by text as fallback
|
||||
model = self.model_combo.get_model()
|
||||
model_found = False
|
||||
for i, row in enumerate(model):
|
||||
if row[0].lower() == self.current_model_size.lower():
|
||||
if row[0].lower() == model_to_set.lower():
|
||||
self.model_combo.set_active(i)
|
||||
model_found = True
|
||||
logger.info(f"Set model by index {i}")
|
||||
@@ -586,8 +647,69 @@ class SettingsDialog(Gtk.Dialog):
|
||||
if self._test_active:
|
||||
return # Don't apply during testing
|
||||
|
||||
# Apply settings in background to avoid blocking UI
|
||||
settings = self.get_selected_settings()
|
||||
engine = settings.get("engine", "vosk")
|
||||
model_name = settings.get("model_size", "small")
|
||||
|
||||
# Check if model needs to be downloaded
|
||||
needs_download = False
|
||||
if engine == "whisper" and not _is_whisper_model_downloaded(model_name):
|
||||
needs_download = True
|
||||
model_info = WHISPER_MODEL_INFO.get(model_name, {"size_mb": 500})
|
||||
elif engine == "vosk" and not _is_vosk_model_downloaded(model_name):
|
||||
needs_download = True
|
||||
model_info = VOSK_MODEL_INFO.get(model_name, {"size_mb": 50})
|
||||
|
||||
if needs_download:
|
||||
# Show download dialog for models that need downloading
|
||||
logger.info(f"Model {model_name} needs download, showing progress dialog")
|
||||
download_dialog = ModelDownloadDialog(
|
||||
self, model_name, model_info["size_mb"], engine=engine
|
||||
)
|
||||
|
||||
def progress_callback(fraction, speed, status):
|
||||
"""Update UI with download progress."""
|
||||
GLib.idle_add(download_dialog.update_progress, fraction, speed, status)
|
||||
|
||||
def download_and_apply():
|
||||
try:
|
||||
# Set up progress callback for downloads (both VOSK and Whisper)
|
||||
self.speech_engine.set_download_progress_callback(progress_callback)
|
||||
|
||||
# Check for cancellation periodically
|
||||
def check_cancelled():
|
||||
if download_dialog.cancelled:
|
||||
self.speech_engine.cancel_download()
|
||||
return not download_dialog.cancelled
|
||||
|
||||
# Start cancellation checker
|
||||
cancel_check_id = GLib.timeout_add(100, check_cancelled)
|
||||
|
||||
try:
|
||||
self._apply_settings_internal(settings)
|
||||
GLib.idle_add(download_dialog.set_complete, True, "")
|
||||
# Refresh model list after download to update icons
|
||||
GLib.idle_add(self._populate_model_options)
|
||||
finally:
|
||||
GLib.source_remove(cancel_check_id)
|
||||
self.speech_engine.set_download_progress_callback(None)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
if "cancelled" in error_msg.lower():
|
||||
GLib.idle_add(download_dialog.set_complete, False, "Download cancelled")
|
||||
elif engine == "whisper" and "no module named" in error_msg.lower():
|
||||
GLib.idle_add(download_dialog.set_complete, False, "Whisper not installed")
|
||||
GLib.idle_add(self._show_whisper_install_dialog)
|
||||
else:
|
||||
GLib.idle_add(download_dialog.set_complete, False, error_msg[:100])
|
||||
|
||||
threading.Thread(target=download_and_apply, daemon=True).start()
|
||||
download_dialog.run()
|
||||
download_dialog.destroy()
|
||||
return
|
||||
|
||||
# Model already downloaded, just apply settings directly
|
||||
logger.info(f"Auto-applying settings: {settings}")
|
||||
|
||||
try:
|
||||
@@ -605,21 +727,29 @@ class SettingsDialog(Gtk.Dialog):
|
||||
"""Show/hide UI elements specific to the selected engine."""
|
||||
selected_engine_text = self.engine_combo.get_active_text()
|
||||
if not selected_engine_text:
|
||||
self.vosk_settings_box.hide()
|
||||
self.recognition_settings_box.hide()
|
||||
self.whisper_info_box.hide()
|
||||
return
|
||||
|
||||
selected_engine = selected_engine_text.lower()
|
||||
|
||||
# Recognition settings (VAD, silence timeout) apply to both engines
|
||||
self.recognition_settings_box.show()
|
||||
|
||||
if selected_engine == "vosk":
|
||||
self.vosk_settings_box.show()
|
||||
# Show VOSK-specific info labels
|
||||
self.vosk_model_info_label.show()
|
||||
self.vosk_recommendation_label.show()
|
||||
self.whisper_info_box.hide()
|
||||
self._update_vosk_info()
|
||||
elif selected_engine == "whisper":
|
||||
self.vosk_settings_box.hide()
|
||||
# Hide VOSK-specific info labels, show Whisper info
|
||||
self.vosk_model_info_label.hide()
|
||||
self.vosk_recommendation_label.hide()
|
||||
self.whisper_info_box.show()
|
||||
self._update_whisper_info()
|
||||
else:
|
||||
self.vosk_settings_box.hide()
|
||||
self.recognition_settings_box.hide()
|
||||
self.whisper_info_box.hide()
|
||||
|
||||
def _update_whisper_info(self):
|
||||
@@ -643,7 +773,9 @@ class SettingsDialog(Gtk.Dialog):
|
||||
if is_downloaded:
|
||||
status_text = "<span foreground='green'>✓ Downloaded and ready</span>"
|
||||
else:
|
||||
status_text = f"<span foreground='orange'>↓ Will download ~{_format_size(info['size_mb'])}</span>"
|
||||
status_text = (
|
||||
f"<span foreground='orange'>↓ Will download ~{_format_size(info['size_mb'])}</span>"
|
||||
)
|
||||
|
||||
info_text = (
|
||||
f"<b>{model_name.capitalize()}</b>: {info['desc']}\n"
|
||||
@@ -684,7 +816,9 @@ class SettingsDialog(Gtk.Dialog):
|
||||
if is_downloaded:
|
||||
status_text = "<span foreground='green'>✓ Downloaded and ready</span>"
|
||||
else:
|
||||
status_text = f"<span foreground='orange'>↓ Will download ~{_format_size(info['size_mb'])}</span>"
|
||||
status_text = (
|
||||
f"<span foreground='orange'>↓ Will download ~{_format_size(info['size_mb'])}</span>"
|
||||
)
|
||||
|
||||
info_text = (
|
||||
f"<b>{model_name.capitalize()}</b>: {info['desc']}\n"
|
||||
@@ -721,11 +855,9 @@ class SettingsDialog(Gtk.Dialog):
|
||||
settings = {
|
||||
"engine": engine,
|
||||
"model_size": model_size,
|
||||
"vad_sensitivity": vad,
|
||||
"silence_timeout": silence,
|
||||
}
|
||||
# Only include VOSK settings if VOSK is selected
|
||||
if engine == "vosk":
|
||||
settings["vad_sensitivity"] = vad
|
||||
settings["silence_timeout"] = silence
|
||||
|
||||
return settings
|
||||
|
||||
@@ -736,24 +868,20 @@ class SettingsDialog(Gtk.Dialog):
|
||||
return
|
||||
|
||||
# Ensure settings are applied before testing
|
||||
current_config = self.config_manager.get_settings().get(
|
||||
"speech_recognition", {}
|
||||
)
|
||||
current_config = self.config_manager.get_settings().get("speech_recognition", {})
|
||||
selected_settings = self.get_selected_settings()
|
||||
|
||||
# Check if settings in dialog differ from saved config
|
||||
# This is a basic check; a more robust diff might be needed
|
||||
settings_differ = False
|
||||
if current_config.get("engine") != selected_settings.get(
|
||||
"engine"
|
||||
) or current_config.get("model_size") != selected_settings.get("model_size"):
|
||||
if current_config.get("engine") != selected_settings.get("engine") or current_config.get(
|
||||
"model_size"
|
||||
) != selected_settings.get("model_size"):
|
||||
settings_differ = True
|
||||
elif selected_settings.get("engine") == "vosk":
|
||||
if current_config.get("vad_sensitivity") != selected_settings.get(
|
||||
"vad_sensitivity"
|
||||
) or current_config.get("silence_timeout") != selected_settings.get(
|
||||
"silence_timeout"
|
||||
):
|
||||
) or current_config.get("silence_timeout") != selected_settings.get("silence_timeout"):
|
||||
settings_differ = True
|
||||
# Add checks for other engines if they get specific settings
|
||||
|
||||
@@ -792,9 +920,7 @@ class SettingsDialog(Gtk.Dialog):
|
||||
self.test_buffer.get_start_iter(), self.test_buffer.get_end_iter(), False
|
||||
)
|
||||
# Add a space if there's existing text
|
||||
separator = (
|
||||
" " if current_text.strip() else ""
|
||||
) # Check strip() to avoid leading space
|
||||
separator = " " if current_text.strip() else "" # Check strip() to avoid leading space
|
||||
self.test_buffer.insert(self.test_buffer.get_end_iter(), separator + text)
|
||||
# Ensure the text view scrolls to the end
|
||||
mark = self.test_buffer.get_insert()
|
||||
@@ -881,76 +1007,65 @@ For now, the engine has been reverted to VOSK."""
|
||||
settings = self.get_selected_settings()
|
||||
logger.info(f"Applying settings: {settings}")
|
||||
|
||||
# Check if we need to download a Whisper model
|
||||
if settings.get("engine") == "whisper":
|
||||
model_name = settings.get("model_size", "base")
|
||||
if not _is_whisper_model_downloaded(model_name):
|
||||
# Show download dialog
|
||||
model_info = WHISPER_MODEL_INFO.get(model_name, {"size_mb": 500})
|
||||
download_dialog = ModelDownloadDialog(
|
||||
self, model_name, model_info["size_mb"], engine="whisper"
|
||||
)
|
||||
engine = settings.get("engine", "vosk")
|
||||
model_name = settings.get("model_size", "small")
|
||||
|
||||
# Check if we need to download a model
|
||||
needs_download = False
|
||||
if engine == "whisper" and not _is_whisper_model_downloaded(model_name):
|
||||
needs_download = True
|
||||
model_info = WHISPER_MODEL_INFO.get(model_name, {"size_mb": 500})
|
||||
elif engine == "vosk" and not _is_vosk_model_downloaded(model_name):
|
||||
needs_download = True
|
||||
model_info = VOSK_MODEL_INFO.get(model_name, {"size_mb": 50})
|
||||
|
||||
if needs_download:
|
||||
# Show download dialog
|
||||
download_dialog = ModelDownloadDialog(
|
||||
self, model_name, model_info["size_mb"], engine=engine
|
||||
)
|
||||
|
||||
def progress_callback(fraction, speed, status):
|
||||
"""Update UI with download progress."""
|
||||
GLib.idle_add(download_dialog.update_progress, fraction, speed, status)
|
||||
|
||||
def download_and_apply():
|
||||
try:
|
||||
# Set up progress callback for downloads (both VOSK and Whisper)
|
||||
self.speech_engine.set_download_progress_callback(progress_callback)
|
||||
|
||||
# Check for cancellation periodically
|
||||
def check_cancelled():
|
||||
if download_dialog.cancelled:
|
||||
self.speech_engine.cancel_download()
|
||||
return not download_dialog.cancelled
|
||||
|
||||
cancel_check_id = GLib.timeout_add(100, check_cancelled)
|
||||
|
||||
# Run the download in a background thread
|
||||
def download_and_apply():
|
||||
try:
|
||||
# The actual download happens when we reconfigure the engine
|
||||
self._apply_settings_internal(settings)
|
||||
GLib.idle_add(download_dialog.set_complete, True, "")
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
if (
|
||||
"whisper" in error_msg.lower()
|
||||
and "no module named" in error_msg.lower()
|
||||
):
|
||||
GLib.idle_add(
|
||||
download_dialog.set_complete,
|
||||
False,
|
||||
"Whisper not installed",
|
||||
)
|
||||
GLib.idle_add(self._show_whisper_install_dialog)
|
||||
else:
|
||||
GLib.idle_add(
|
||||
download_dialog.set_complete, False, error_msg[:100]
|
||||
)
|
||||
finally:
|
||||
GLib.source_remove(cancel_check_id)
|
||||
self.speech_engine.set_download_progress_callback(None)
|
||||
|
||||
threading.Thread(target=download_and_apply, daemon=True).start()
|
||||
download_dialog.run()
|
||||
download_dialog.destroy()
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
if "cancelled" in error_msg.lower():
|
||||
GLib.idle_add(download_dialog.set_complete, False, "Download cancelled")
|
||||
elif engine == "whisper" and "no module named" in error_msg.lower():
|
||||
GLib.idle_add(download_dialog.set_complete, False, "Whisper not installed")
|
||||
GLib.idle_add(self._show_whisper_install_dialog)
|
||||
else:
|
||||
GLib.idle_add(download_dialog.set_complete, False, error_msg[:100])
|
||||
|
||||
# Refresh the model list to show updated download status
|
||||
self._populate_model_options()
|
||||
return True
|
||||
threading.Thread(target=download_and_apply, daemon=True).start()
|
||||
download_dialog.run()
|
||||
download_dialog.destroy()
|
||||
|
||||
# Check if we need to download a VOSK model
|
||||
elif settings.get("engine") == "vosk":
|
||||
model_name = settings.get("model_size", "small")
|
||||
if not _is_vosk_model_downloaded(model_name):
|
||||
# Show download dialog
|
||||
model_info = VOSK_MODEL_INFO.get(model_name, {"size_mb": 50})
|
||||
download_dialog = ModelDownloadDialog(
|
||||
self, model_name, model_info["size_mb"], engine="vosk"
|
||||
)
|
||||
|
||||
# Run the download in a background thread
|
||||
def download_and_apply():
|
||||
try:
|
||||
# The actual download happens when we reconfigure the engine
|
||||
self._apply_settings_internal(settings)
|
||||
GLib.idle_add(download_dialog.set_complete, True, "")
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
GLib.idle_add(
|
||||
download_dialog.set_complete, False, error_msg[:100]
|
||||
)
|
||||
|
||||
threading.Thread(target=download_and_apply, daemon=True).start()
|
||||
download_dialog.run()
|
||||
download_dialog.destroy()
|
||||
|
||||
# Refresh the model list to show updated download status
|
||||
self._populate_model_options()
|
||||
return True
|
||||
# Refresh the model list to show updated download status
|
||||
self._populate_model_options()
|
||||
return True
|
||||
|
||||
return self._apply_settings_internal(settings)
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -4,4 +4,4 @@ Utility modules for Vocalinux.
|
||||
|
||||
from .resource_manager import ResourceManager
|
||||
|
||||
__all__ = ["ResourceManager"]
|
||||
__all__ = ["ResourceManager"]
|
||||
|
||||
@@ -17,36 +17,36 @@ logger = logging.getLogger(__name__)
|
||||
class ResourceManager:
|
||||
"""
|
||||
Centralized manager for application resources.
|
||||
|
||||
|
||||
This class provides a unified way to locate resources like icons and sounds
|
||||
regardless of whether the application is running from source, installed via pip,
|
||||
or installed system-wide.
|
||||
"""
|
||||
|
||||
|
||||
_instance = None
|
||||
_resources_dir = None
|
||||
|
||||
|
||||
def __new__(cls):
|
||||
"""Singleton pattern to ensure only one instance exists."""
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the resource manager."""
|
||||
if self._resources_dir is None:
|
||||
self._resources_dir = self._find_resources_dir()
|
||||
|
||||
|
||||
def _find_resources_dir(self) -> str:
|
||||
"""
|
||||
Find the resources directory regardless of how the application is executed.
|
||||
|
||||
|
||||
Returns:
|
||||
Path to the resources directory
|
||||
"""
|
||||
# Get the directory where this module is located
|
||||
module_dir = Path(__file__).parent.absolute()
|
||||
|
||||
|
||||
# Try several methods to find the resources directory
|
||||
candidates = [
|
||||
# For direct repository execution (go up from src/vocalinux/utils to root)
|
||||
@@ -59,70 +59,72 @@ class ResourceManager:
|
||||
Path("/usr/local/share/vocalinux/resources"),
|
||||
Path("/usr/share/vocalinux/resources"),
|
||||
]
|
||||
|
||||
|
||||
# Log all candidates for debugging
|
||||
for candidate in candidates:
|
||||
logger.debug(f"Checking resources candidate: {candidate} (exists: {candidate.exists()})")
|
||||
|
||||
logger.debug(
|
||||
f"Checking resources candidate: {candidate} (exists: {candidate.exists()})"
|
||||
)
|
||||
|
||||
# Return the first candidate that exists
|
||||
for candidate in candidates:
|
||||
if candidate.exists():
|
||||
logger.info(f"Found resources directory: {candidate}")
|
||||
return str(candidate)
|
||||
|
||||
|
||||
# If no candidate exists, default to the first one (with warning)
|
||||
default_path = str(candidates[0])
|
||||
logger.warning(f"Could not find resources directory, defaulting to: {default_path}")
|
||||
return default_path
|
||||
|
||||
|
||||
@property
|
||||
def resources_dir(self) -> str:
|
||||
"""Get the resources directory path."""
|
||||
return self._resources_dir
|
||||
|
||||
|
||||
@property
|
||||
def icons_dir(self) -> str:
|
||||
"""Get the icons directory path."""
|
||||
return os.path.join(self._resources_dir, "icons", "scalable")
|
||||
|
||||
|
||||
@property
|
||||
def sounds_dir(self) -> str:
|
||||
"""Get the sounds directory path."""
|
||||
return os.path.join(self._resources_dir, "sounds")
|
||||
|
||||
|
||||
def get_icon_path(self, icon_name: str) -> str:
|
||||
"""
|
||||
Get the full path to an icon file.
|
||||
|
||||
|
||||
Args:
|
||||
icon_name: Name of the icon (without extension)
|
||||
|
||||
|
||||
Returns:
|
||||
Full path to the icon file
|
||||
"""
|
||||
return os.path.join(self.icons_dir, f"{icon_name}.svg")
|
||||
|
||||
|
||||
def get_sound_path(self, sound_name: str) -> str:
|
||||
"""
|
||||
Get the full path to a sound file.
|
||||
|
||||
|
||||
Args:
|
||||
sound_name: Name of the sound file (without extension)
|
||||
|
||||
|
||||
Returns:
|
||||
Full path to the sound file
|
||||
"""
|
||||
return os.path.join(self.sounds_dir, f"{sound_name}.wav")
|
||||
|
||||
|
||||
def ensure_directories_exist(self):
|
||||
"""Ensure that resource directories exist."""
|
||||
os.makedirs(self.icons_dir, exist_ok=True)
|
||||
os.makedirs(self.sounds_dir, exist_ok=True)
|
||||
|
||||
|
||||
def validate_resources(self) -> dict:
|
||||
"""
|
||||
Validate that all expected resources exist.
|
||||
|
||||
|
||||
Returns:
|
||||
Dictionary with validation results
|
||||
"""
|
||||
@@ -133,30 +135,26 @@ class ResourceManager:
|
||||
"missing_icons": [],
|
||||
"missing_sounds": [],
|
||||
}
|
||||
|
||||
|
||||
# Expected icons
|
||||
expected_icons = [
|
||||
"vocalinux",
|
||||
"vocalinux-microphone",
|
||||
"vocalinux-microphone-off",
|
||||
"vocalinux-microphone-process"
|
||||
"vocalinux-microphone-off",
|
||||
"vocalinux-microphone-process",
|
||||
]
|
||||
|
||||
|
||||
for icon in expected_icons:
|
||||
icon_path = self.get_icon_path(icon)
|
||||
if not os.path.exists(icon_path):
|
||||
results["missing_icons"].append(icon)
|
||||
|
||||
|
||||
# Expected sounds
|
||||
expected_sounds = [
|
||||
"start_recording",
|
||||
"stop_recording",
|
||||
"error"
|
||||
]
|
||||
|
||||
expected_sounds = ["start_recording", "stop_recording", "error"]
|
||||
|
||||
for sound in expected_sounds:
|
||||
sound_path = self.get_sound_path(sound)
|
||||
if not os.path.exists(sound_path):
|
||||
results["missing_sounds"].append(sound)
|
||||
|
||||
return results
|
||||
|
||||
return results
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
Version information for Vocalinux.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.2.0-alpha"
|
||||
__version_info__ = (0, 2, 0, "alpha")
|
||||
__author__ = "Jatin K Malik"
|
||||
__email__ = "jatinkrmalik@gmail.com"
|
||||
__license__ = "GPL-3.0"
|
||||
__copyright__ = "Copyright 2023-2024 Jatin K Malik"
|
||||
__copyright__ = "Copyright 2023-2025 Jatin K Malik"
|
||||
__url__ = "https://github.com/jatinkrmalik/vocalinux"
|
||||
__description__ = "A seamless voice dictation system for Linux"
|
||||
|
||||
+8
-16
@@ -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)"
|
||||
)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -16,9 +16,7 @@ class TestKeyboardShortcuts(unittest.TestCase):
|
||||
def setUp(self):
|
||||
"""Set up for tests."""
|
||||
# Set up more complete mocks for the keyboard library
|
||||
self.kb_patch = patch(
|
||||
"vocalinux.ui.keyboard_shortcuts.KEYBOARD_AVAILABLE", True
|
||||
)
|
||||
self.kb_patch = patch("vocalinux.ui.keyboard_shortcuts.KEYBOARD_AVAILABLE", True)
|
||||
self.kb_patch.start()
|
||||
|
||||
# Create proper Key enum and KeyCode class
|
||||
@@ -167,9 +165,7 @@ class TestKeyboardShortcuts(unittest.TestCase):
|
||||
self.ksm.register_toggle_callback(callback)
|
||||
|
||||
# Set up initial state
|
||||
self.ksm.last_ctrl_press_time = (
|
||||
time.time() - 0.2
|
||||
) # Recent press (within threshold)
|
||||
self.ksm.last_ctrl_press_time = time.time() - 0.2 # Recent press (within threshold)
|
||||
self.ksm.last_trigger_time = 0 # No recent triggers
|
||||
|
||||
# Simulate second Ctrl press (should trigger callback)
|
||||
@@ -219,12 +215,8 @@ class TestKeyboardShortcuts(unittest.TestCase):
|
||||
self.ksm.register_toggle_callback(callback)
|
||||
|
||||
# Set up initial state (recent trigger)
|
||||
self.ksm.last_ctrl_press_time = (
|
||||
time.time() - 0.2
|
||||
) # Recent press (within threshold)
|
||||
self.ksm.last_trigger_time = (
|
||||
time.time() - 0.2
|
||||
) # Recent trigger, within debounce
|
||||
self.ksm.last_ctrl_press_time = time.time() - 0.2 # Recent press (within threshold)
|
||||
self.ksm.last_trigger_time = time.time() - 0.2 # Recent trigger, within debounce
|
||||
|
||||
# Simulate second Ctrl press (should NOT trigger due to debounce)
|
||||
on_press(self.mock_keyboard.Key.ctrl)
|
||||
|
||||
+15
-2
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -93,14 +93,18 @@ class TestWhisperSupport:
|
||||
model_mock = MagicMock()
|
||||
whisper_mock.load_model.return_value = model_mock
|
||||
|
||||
# Patch modules
|
||||
with patch.dict(sys.modules, {"whisper": whisper_mock, "torch": torch_mock}):
|
||||
# Patch modules and mock the download method to avoid network/file operations
|
||||
with patch.dict(sys.modules, {"whisper": whisper_mock, "torch": torch_mock}), patch(
|
||||
"vocalinux.speech_recognition.recognition_manager.SpeechRecognitionManager._download_whisper_model"
|
||||
) as mock_download, patch("os.path.exists", return_value=True):
|
||||
from vocalinux.speech_recognition.recognition_manager import (
|
||||
SpeechRecognitionManager,
|
||||
)
|
||||
|
||||
# Create manager with Whisper engine
|
||||
manager = SpeechRecognitionManager(engine="whisper", model_size="base")
|
||||
# Create manager with Whisper engine and defer_download=False to trigger model loading
|
||||
manager = SpeechRecognitionManager(
|
||||
engine="whisper", model_size="base", defer_download=False
|
||||
)
|
||||
|
||||
# Verify Whisper was initialized correctly
|
||||
assert manager.engine == "whisper"
|
||||
|
||||
+4
-3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user