From 114d86b0ef14e9b5da3f6f0c75683d314fd72f26 Mon Sep 17 00:00:00 2001 From: goodroot <9484709+goodroot@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:56:32 -0700 Subject: [PATCH 1/3] perf(install): re-align prebuilt wheel pipeline to v1.4.1 + add CI drift guard The installer requested pywhispercpp 1.4.1 wheels but the workflow built 1.4.0, so every download 404'd and silently fell back to a slow source build (NVIDIA) or PyPI (CPU). Make backend_installer.py the single source of truth: the workflow derives version, CUDA variant, and expected filename by importing it. Build one cuda12 wheel per CUDA major, drop self-hosted CPU wheels (PyPI already ships them), and gate publish on a verify-wheels job that fails on any name/version drift. Point WHEEL_BASE_URL at wheels-v2 and remove dead _parallel_deps_and_wheel. --- .github/workflows/build-wheels.yml | 152 +++++++++++++++++------------ lib/src/backend_installer.py | 133 ++++--------------------- 2 files changed, 104 insertions(+), 181 deletions(-) diff --git a/.github/workflows/build-wheels.yml b/.github/workflows/build-wheels.yml index 64c3b871..fa0a52bb 100644 --- a/.github/workflows/build-wheels.yml +++ b/.github/workflows/build-wheels.yml @@ -1,21 +1,25 @@ name: Build PyWhisperCPP Wheels +# CUDA wheels only; CPU comes from PyPI. Version and variant suffix are derived +# from lib/src/backend_installer.py so the workflow never restates the contract. + on: workflow_dispatch: inputs: pywhispercpp_commit: - description: 'PyWhisperCPP commit hash' + description: 'PyWhisperCPP commit (defaults to backend_installer.PYWHISPERCPP_PINNED_COMMIT)' required: false - default: '4ab96165f84e8eb579077dfc3d0476fa5606affe' + default: 'd8f202f4435cf3e5e0bf735723eaacbe84c6853c' # v1.4.1 release: types: [published] env: - PYWHISPERCPP_COMMIT: ${{ github.event.inputs.pywhispercpp_commit || '4ab96165f84e8eb579077dfc3d0476fa5606affe' }} + PYWHISPERCPP_COMMIT: ${{ github.event.inputs.pywhispercpp_commit || 'd8f202f4435cf3e5e0bf735723eaacbe84c6853c' }} + CUDA_BUILD_VERSION: '12.6' # one 12.x build serves all CUDA 12 jobs: - build-cpu: - name: Build CPU wheel (Python ${{ matrix.python-version }}) + build-cuda: + name: Build CUDA wheel (Python ${{ matrix.python-version }}) runs-on: ubuntu-22.04 strategy: fail-fast: false @@ -30,6 +34,36 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true + + - name: Derive installer contract (from backend_installer.py) + run: | + ver=$(PYTHONPATH=lib/src python -c "import backend_installer as b; print(b.PYWHISPERCPP_VERSION)") + pin=$(PYTHONPATH=lib/src python -c "import backend_installer as b; print(b.PYWHISPERCPP_PINNED_COMMIT)") + variant=$(PYTHONPATH=lib/src python -c "import backend_installer as b; print(b._get_wheel_variant('${CUDA_BUILD_VERSION}'))") + echo "EXPECTED_VERSION=$ver" >> "$GITHUB_ENV" + echo "VARIANT=$variant" >> "$GITHUB_ENV" + echo "contract: version=$ver variant=$variant pin=$pin" + if [ "$variant" != "cuda12" ]; then + echo "::error::Installer maps CUDA ${CUDA_BUILD_VERSION} to '$variant', expected 'cuda12'." + exit 1 + fi + if [ "${PYWHISPERCPP_COMMIT}" != "$pin" ]; then + echo "::warning::Building ${PYWHISPERCPP_COMMIT}, installer pins $pin (v$ver)." + fi + + - name: Install CUDA Toolkit ${{ env.CUDA_BUILD_VERSION }} + uses: Jimver/cuda-toolkit@v0.2.35 + id: cuda-toolkit + with: + cuda: ${{ env.CUDA_BUILD_VERSION }}.0 + method: 'network' + sub-packages: '["nvcc", "cudart-dev"]' + + - name: Install additional CUDA libraries + run: | + CUDA_VER_DASH="${CUDA_BUILD_VERSION//./-}" + sudo apt-get install -y libcublas-dev-${CUDA_VER_DASH} - name: Install build dependencies run: | @@ -44,35 +78,46 @@ jobs: git checkout ${{ env.PYWHISPERCPP_COMMIT }} git submodule update --init --recursive - - name: Build wheel + - name: Build wheel with CUDA support + env: + GGML_CUDA: 'ON' + CUDACXX: ${{ steps.cuda-toolkit.outputs.CUDA_PATH }}/bin/nvcc + NO_REPAIR: '1' # CUDA driver libs come from the user's system run: | cd pywhispercpp-src pip wheel . --no-deps --wheel-dir ../dist - - name: Rename wheel with variant suffix + - name: Assert built version matches installer + run: | + cd dist + built="$(ls pywhispercpp-*.whl | head -n1)" + version="$(echo "$built" | sed -E 's/^pywhispercpp-([^-]+)-.*/\1/')" + if [ "$version" != "${EXPECTED_VERSION}" ]; then + echo "::error::Built $version != installer's ${EXPECTED_VERSION}." + exit 1 + fi + + - name: Rename wheel with CUDA variant suffix run: | cd dist - for f in *.whl; do - # Add +cpu suffix before .whl extension - newname="${f%.whl}+cpu.whl" - mv "$f" "$newname" - done + for f in *.whl; do mv "$f" "${f%.whl}+${VARIANT}.whl"; done ls -la - name: Upload wheel artifact uses: actions/upload-artifact@v4 with: - name: wheel-cpu-py${{ matrix.python-version }} + name: wheel-cuda12-py${{ matrix.python-version }} path: dist/*.whl - build-cuda: - name: Build CUDA ${{ matrix.cuda-version }} wheel (Python ${{ matrix.python-version }}) + verify-wheels: + # Derive the filename the installer will request and assert the build made it. + name: Verify wheel (Python ${{ matrix.python-version }}) + needs: [build-cuda] runs-on: ubuntu-22.04 strategy: fail-fast: false matrix: python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] - cuda-version: ['12.6', '12.9'] steps: - name: Checkout hyprwhspr @@ -82,64 +127,41 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - - name: Install CUDA Toolkit ${{ matrix.cuda-version }} - uses: Jimver/cuda-toolkit@v0.2.35 - id: cuda-toolkit - with: - cuda: ${{ matrix.cuda-version }}.0 - method: 'network' - sub-packages: '["nvcc", "cudart-dev"]' - - - name: Install additional CUDA libraries + - name: Derive expected filename from installer run: | - CUDA_VER="${{ matrix.cuda-version }}" - CUDA_VER_DASH="${CUDA_VER//./-}" # 11.8 -> 11-8 - sudo apt-get install -y libcublas-dev-${CUDA_VER_DASH} + py='${{ matrix.python-version }}' + variant=$(PYTHONPATH=lib/src python -c "import backend_installer as b; print(b._get_wheel_variant('${CUDA_BUILD_VERSION}'))") + dl=$(PYTHONPATH=lib/src python -c "import backend_installer as b; print(b._get_wheel_filename('${py}', '${variant}', True))") + pip=$(PYTHONPATH=lib/src python -c "import backend_installer as b; print(b._get_wheel_filename('${py}', '${variant}', False))") + echo "EXPECTED_WHEEL=$dl" >> "$GITHUB_ENV" + echo "WHEEL_PIP_NAME=$pip" >> "$GITHUB_ENV" + echo "installer wants: $dl" + + - name: Download wheel artifact + uses: actions/download-artifact@v4 + with: + name: wheel-cuda12-py${{ matrix.python-version }} + path: dist - - name: Install build dependencies + - name: Assert build produced the requested file run: | - sudo apt-get update - sudo apt-get install -y build-essential cmake git - pip install --upgrade pip wheel setuptools build + if [ ! -f "dist/${EXPECTED_WHEEL}" ]; then + echo "::error::Installer wants ${EXPECTED_WHEEL}; build did not produce it." + ls -la dist + exit 1 + fi - - name: Clone pywhispercpp + - name: Confirm wheel installs on this Python run: | - git clone --recurse-submodules https://github.com/Absadiki/pywhispercpp.git pywhispercpp-src - cd pywhispercpp-src - git checkout ${{ env.PYWHISPERCPP_COMMIT }} - git submodule update --init --recursive - - - name: Build wheel with CUDA support - env: - GGML_CUDA: 'ON' - CUDACXX: ${{ steps.cuda-toolkit.outputs.CUDA_PATH }}/bin/nvcc - # Skip wheel repair - CUDA driver libs must come from user's system - NO_REPAIR: '1' - run: | - cd pywhispercpp-src - pip wheel . --no-deps --wheel-dir ../dist - - - name: Rename wheel with CUDA variant suffix - run: | - cd dist - cuda_suffix="cuda${{ matrix.cuda-version }}" - cuda_suffix="${cuda_suffix//.}" # Remove dots: cuda118, cuda122 - for f in *.whl; do - newname="${f%.whl}+${cuda_suffix}.whl" - mv "$f" "$newname" - done - ls -la - - - name: Upload wheel artifact - uses: actions/upload-artifact@v4 - with: - name: wheel-cuda${{ matrix.cuda-version }}-py${{ matrix.python-version }} - path: dist/*.whl + cp "dist/${EXPECTED_WHEEL}" "dist/${WHEEL_PIP_NAME}" + # --no-deps: validate cp tag only; import needs a GPU absent on CI. + pip install --no-deps "dist/${WHEEL_PIP_NAME}" publish-wheels: name: Publish wheels to release - needs: [build-cpu, build-cuda] + needs: [build-cuda, verify-wheels] runs-on: ubuntu-latest if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' permissions: diff --git a/lib/src/backend_installer.py b/lib/src/backend_installer.py index 19fddbaa..a7d5ed5e 100644 --- a/lib/src/backend_installer.py +++ b/lib/src/backend_installer.py @@ -77,7 +77,7 @@ def run_sudo_command(cmd: list, check: bool = True, input_data: Optional[bytes] PARAKEET_REQUIREMENTS = PARAKEET_DIR / 'requirements.txt' # Pre-built wheel configuration -WHEEL_BASE_URL = "https://github.com/goodroot/hyprwhspr/releases/download/wheels-v1" +WHEEL_BASE_URL = "https://github.com/goodroot/hyprwhspr/releases/download/wheels-v2" WHEEL_CACHE_DIR = USER_BASE / 'wheel-cache' PYWHISPERCPP_VERSION = "1.4.1" @@ -417,28 +417,20 @@ def _detect_cuda_version() -> Optional[str]: def _get_wheel_variant(cuda_version: Optional[str]) -> Optional[str]: - """Get wheel variant suffix based on CUDA version. + """CUDA wheel variant for the detected CUDA version, or None to source-build. - Returns None if no compatible pre-built wheel exists (triggers source build). + We self-host CUDA wheels only; CPU installs come from PyPI. """ if not cuda_version: - return "cpu" - - major, minor = cuda_version.split('.')[:2] - major = int(major) - - # Map CUDA versions to our pre-built wheel variants - # Only return variants we actually have pre-built wheels for - if major == 11: - return "cuda118" # All CUDA 11.x uses 11.8 build - elif major == 12: - return "cuda122" # All CUDA 12.x uses 12.2 build - elif major >= 13: - # CUDA 13+ not yet available in GitHub Actions - fall back to source build - log_info(f"CUDA {cuda_version} detected - no pre-built wheel available, building from source") return None - else: - return "cpu" # Very old CUDA, fallback to CPU + + major = int(cuda_version.split('.')[0]) + if major == 12: # one 12.x wheel serves all CUDA 12 + return "cuda12" + + # CUDA 11 (EOL) and 13 (not yet CI-buildable) fall back to source. + log_info(f"CUDA {cuda_version} detected - no pre-built wheel available, building from source") + return None def _get_wheel_filename(python_version: str, variant: str, for_download: bool = True) -> str: @@ -446,14 +438,14 @@ def _get_wheel_filename(python_version: str, variant: str, for_download: bool = Args: python_version: e.g., '3.11' - variant: 'cpu', 'cuda118', 'cuda122' + variant: 'cuda12' (CUDA wheels are the only variant we self-host) for_download: If True, include variant suffix (for GitHub). If False, standard pip format. """ # Python 3.11 -> cp311 py_tag = f"cp{python_version.replace('.', '')}" base = f"pywhispercpp-{PYWHISPERCPP_VERSION}-{py_tag}-{py_tag}-linux_x86_64" if for_download: - # GitHub release filename: pywhispercpp--cp311-cp311-linux_x86_64+cuda122.whl + # GitHub release filename: pywhispercpp--cp311-cp311-linux_x86_64+cuda12.whl return f"{base}+{variant}.whl" else: # Standard pip-compatible filename: pywhispercpp--cp311-cp311-linux_x86_64.whl @@ -465,8 +457,8 @@ def download_pywhispercpp_wheel(variant: Optional[str] = None) -> Optional[Path] Download pre-built pywhispercpp wheel if available. Args: - variant: Optional variant override ('cpu', 'cuda118', 'cuda122'). - If None, auto-detects based on system CUDA. + variant: Optional variant override ('cuda12'). If None, auto-detects + based on system CUDA. CPU installs come from PyPI, not here. Returns: Path to downloaded wheel file (with pip-compatible name), or None if unavailable/failed. @@ -1350,40 +1342,11 @@ def _filter_requirements(requirements_file: Path, skip_packages: list) -> Path: def install_pywhispercpp_cpu(pip_bin: Path, requirements_file: Path) -> bool: - """Install CPU-only pywhispercpp""" + """Install CPU-only pywhispercpp from PyPI (PyPI ships CPU wheels for all + supported Pythons; we self-host CUDA only).""" log_info("Installing pywhispercpp (CPU-only)...") - # Track if wheel was successfully installed (to avoid overwriting with PyPI version) - wheel_installed = False - - # Try pre-built wheel first (faster than pip resolving from PyPI) - wheel_path = download_pywhispercpp_wheel(variant='cpu') - if wheel_path: - if install_pywhispercpp_from_wheel(pip_bin, wheel_path): - wheel_installed = True - # Still need to install other requirements - skip_packages = ['pywhispercpp'] - if _should_skip_pygobject(): - skip_packages.append('PyGObject') - temp_req_path = None - try: - temp_req_path = _filter_requirements(requirements_file, skip_packages) - run_command([str(pip_bin), 'install', '-r', str(temp_req_path)], check=True) - set_state("installed_backend", "cpu") - return True - except subprocess.CalledProcessError as e: - log_warning(f"Wheel installed but remaining deps failed: {e}") - log_warning("Falling back to full pip install...") - finally: - if temp_req_path and temp_req_path.exists(): - temp_req_path.unlink() - else: - log_warning("Pre-built wheel failed, falling back to pip install...") - - # Build skip list - always skip pywhispercpp if wheel was already installed skip_packages = [] - if wheel_installed: - skip_packages.append('pywhispercpp') if _should_skip_pygobject(): skip_packages.append('PyGObject') @@ -2165,68 +2128,6 @@ def setup_venv(): return gpu_status, pip_bin -def _parallel_deps_and_wheel(pip_bin: Path, requirements_file: Path, variant: str) -> Tuple[bool, Optional[Path]]: - """ - Download wheel and install base dependencies in parallel. - - Args: - pip_bin: Path to pip in venv - requirements_file: Path to requirements.txt - variant: Wheel variant ('cpu', 'cuda118', 'cuda122') - - Returns: - Tuple of (deps_installed bool, wheel_path or None) - """ - deps_ok = False - wheel_path = None - errors = [] - - def install_deps(): - """Install base dependencies (excluding pywhispercpp)""" - nonlocal deps_ok - try: - # Filter out pywhispercpp from requirements - skip_packages = ['pywhispercpp'] - if _should_skip_pygobject(): - skip_packages.append('PyGObject') - - temp_req_path = None - try: - temp_req_path = _filter_requirements(requirements_file, skip_packages) - run_command([str(pip_bin), 'install', '-r', str(temp_req_path)], check=True) - deps_ok = True - finally: - if temp_req_path and temp_req_path.exists(): - temp_req_path.unlink() - except Exception as e: - errors.append(f"Deps install error: {e}") - - def download_wheel(): - """Download pre-built wheel""" - nonlocal wheel_path - try: - wheel_path = download_pywhispercpp_wheel(variant=variant) - except Exception as e: - errors.append(f"Wheel download error: {e}") - - # Run both in parallel - with ThreadPoolExecutor(max_workers=2) as executor: - deps_future = executor.submit(install_deps) - wheel_future = executor.submit(download_wheel) - - for future in as_completed([deps_future, wheel_future]): - try: - future.result() - except Exception as e: - errors.append(str(e)) - - if errors: - for error in errors: - log_debug(error) - - return deps_ok, wheel_path - - # ==================== Main Installation Function ==================== def install_backend(backend_type: str, cleanup_on_failure: bool = True, force_rebuild: bool = False, From 853e8c5454d16812ef53dd98b338d6c444ae6419 Mon Sep 17 00:00:00 2001 From: goodroot <9484709+goodroot@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:56:32 -0700 Subject: [PATCH 2/3] perf(config): raise default whisper threads and seed product word override threads now defaults to min(8, os.cpu_count() or 4) instead of a hardcoded 4. Seed a default word override 'hyper whisper' -> 'hyprwhspr' for the product name; users with their own overrides are never clobbered (config load is a top-level dict replace, so a user's word_overrides fully wins). Update schema (drop pinned numeric threads default) and docs (omit-for-auto note, language auto-detect latency tip) to match. --- docs/CONFIGURATION.md | 4 +++- lib/src/config_manager.py | 3 ++- share/config.schema.json | 5 ++--- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 5e544059..31447005 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -440,7 +440,7 @@ Set model in config (pywhispercpp only — faster-whisper uses `faster_whisper_m ```jsonc { "model": "small.en", // .en = English-only; omit suffix for multilingual - "threads": 4 // CPU threads for whisper processing (default: 4) + // "threads": 6 // optional; omit for auto = min(8, CPU count) } ``` @@ -456,6 +456,8 @@ For multi-language detection, ensure you select a model which does not say `.en` } ``` +Auto-detect runs an extra detection pass per utterance. Setting `language` explicitly skips it and lowers latency. + Language options: - **`null`** (default) - Auto-detect language from audio diff --git a/lib/src/config_manager.py b/lib/src/config_manager.py index fd83fd54..904767d7 100644 --- a/lib/src/config_manager.py +++ b/lib/src/config_manager.py @@ -73,7 +73,7 @@ def __init__(self, verbose: bool = True): 'audio_device_model_id': None, # USB model ID (most stable, from udev) 'model': 'base', 'language': None, # Language code for transcription (None = auto-detect, or 'en', 'nl', 'fr', etc.) - 'word_overrides': {}, # Dictionary of word replacements: {"original": "replacement"} + 'word_overrides': {'hyper whisper': 'hyprwhspr'}, # {"original": "replacement"} 'filter_filler_words': False, # Remove common filler words (uh, um, er, etc.) 'filler_words': ['uh', 'um', 'er', 'ah', 'eh', 'hmm', 'hm', 'mm', 'mhm'], # Filler words to remove 'symbol_replacements': True, # Enable built-in speech-to-symbol replacements (e.g., "quote" → ") @@ -81,6 +81,7 @@ def __init__(self, verbose: bool = True): 'task': 'transcribe', # "transcribe" (source language) or "translate" (to English) 'sampling_strategy': 'beam_search', # "beam_search" or "greedy" for Whisper decoding 'beam_size': 5, # Number of candidates tracked when using beam search + 'threads': min(8, os.cpu_count() or 4), # whisper.cpp worker threads # Shell command run after preprocessing, before paste. Stdin # receives the transcription; non-empty stdout replaces it. # Empty stdout leaves text unchanged (observer-only hooks). diff --git a/share/config.schema.json b/share/config.schema.json index d0d79e12..50853cee 100644 --- a/share/config.schema.json +++ b/share/config.schema.json @@ -100,8 +100,7 @@ "threads": { "type": "integer", "minimum": 1, - "default": 4, - "description": "Thread count for whisper processing" + "description": "Thread count for whisper processing; omit for auto = min(8, CPU count)" }, "language": { "type": ["string", "null"], @@ -113,7 +112,7 @@ "additionalProperties": { "type": "string" }, - "default": {}, + "default": {"hyper whisper": "hyprwhspr"}, "description": "Dictionary of word replacements applied after transcription: {\"original\": \"replacement\"}" }, "filter_filler_words": { From 875e087fb62c9694348c1fd830a3665c7df282f0 Mon Sep 17 00:00:00 2001 From: goodroot <9484709+goodroot@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:16:54 -0700 Subject: [PATCH 3/3] test+fix: address CI review on wheel pipeline and defaults - build-cuda: apt-get update before installing libcublas (fresh-runner flakiness) - CPU install: --only-binary=pywhispercpp so a missing PyPI wheel fails clearly instead of silently attempting a source build - CPU install: post-install 'import pywhispercpp' check (using the pip_bin-paired interpreter) so a pip success that didn't install the backend fails cleanly - _detect_venv_python_version: delegate to _get_python_version so a stderr-printing interpreter no longer falls back to the wrong (system) Python and picks a wrong wheel filename - verify-wheels: ldd smoke check fails on any non-CUDA unresolved lib (a real import can't run on GPU-less CI) - tests: _get_wheel_variant / _get_wheel_filename (CUDA 11/12/13/none, py3.10-3.14) and config defaults (threads, word_overrides incl. existing-user preservation) - docs: note the product word override ships by default --- .github/workflows/build-wheels.yml | 15 +++++ docs/CONFIGURATION.md | 2 + lib/src/backend_installer.py | 41 ++++++------ tests/test_wheel_pipeline_and_defaults.py | 78 +++++++++++++++++++++++ 4 files changed, 113 insertions(+), 23 deletions(-) create mode 100644 tests/test_wheel_pipeline_and_defaults.py diff --git a/.github/workflows/build-wheels.yml b/.github/workflows/build-wheels.yml index fa0a52bb..78632ce8 100644 --- a/.github/workflows/build-wheels.yml +++ b/.github/workflows/build-wheels.yml @@ -62,6 +62,7 @@ jobs: - name: Install additional CUDA libraries run: | + sudo apt-get update CUDA_VER_DASH="${CUDA_BUILD_VERSION//./-}" sudo apt-get install -y libcublas-dev-${CUDA_VER_DASH} @@ -159,6 +160,20 @@ jobs: # --no-deps: validate cp tag only; import needs a GPU absent on CI. pip install --no-deps "dist/${WHEEL_PIP_NAME}" + - name: Smoke-check native lib linkage + run: | + site=$(python -c "import sysconfig; print(sysconfig.get_paths()['platlib'])") + sos=$(find "$site" -maxdepth 2 \( -name '*.so' -o -name '*.so.*' \) | grep -iE 'pywhispercpp|whisper|ggml' || true) + [ -n "$sos" ] || { echo "::error::no pywhispercpp native libs found in $site"; exit 1; } + # CUDA driver/runtime libs are legitimately absent on GPU-less CI; anything else unresolved means a broken wheel. + missing=$(ldd $sos 2>/dev/null | grep 'not found' | grep -viE 'libcuda|libnvidia|libcublas|libcudart|libnvrtc' | sort -u || true) + if [ -n "$missing" ]; then + echo "::error::Non-CUDA shared libs unresolved (wheel likely unusable):" + echo "$missing" + exit 1 + fi + echo "OK: only CUDA libs unresolved (expected without a GPU)" + publish-wheels: name: Publish wheels to release needs: [build-cuda, verify-wheels] diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 31447005..61ba64ef 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -768,6 +768,8 @@ Customize transcriptions: Use empty string `""` to delete words entirely. +`{"hyper whisper": "hyprwhspr"}` ships as the default (the product name is spoken "hyper whisper"). + Single-character overrides match anywhere in a word (not just at word boundaries): ```jsonc diff --git a/lib/src/backend_installer.py b/lib/src/backend_installer.py index a7d5ed5e..a08c6263 100644 --- a/lib/src/backend_installer.py +++ b/lib/src/backend_installer.py @@ -355,29 +355,13 @@ def _get_system_python() -> str: # ==================== Pre-built Wheel Support ==================== def _detect_venv_python_version() -> str: - """Detect Python version in the venv (e.g., '3.11')""" + """Detect Python version in the venv (e.g., '3.11').""" venv_python = VENV_DIR / 'bin' / 'python' - if not venv_python.exists(): - # Fallback to system Python version - import re - version = f"{sys.version_info.major}.{sys.version_info.minor}" - return version - - try: - result = run_command( - [str(venv_python), '--version'], - check=False, - capture_output=True - ) - output = _safe_decode(result.stdout) - # Parse "Python 3.11.5" -> "3.11" - import re - match = re.search(r'(\d+)\.(\d+)', output) - if match: - return f"{match.group(1)}.{match.group(2)}" - except Exception: - pass - + if venv_python.exists(): + # _get_python_version handles interpreters that print --version to stderr. + version = _get_python_version(str(venv_python)) + if version: + return f"{version[0]}.{version[1]}" return f"{sys.version_info.major}.{sys.version_info.minor}" @@ -1358,7 +1342,18 @@ def install_pywhispercpp_cpu(pip_bin: Path, requirements_file: Path) -> bool: else: install_file = requirements_file - run_command([str(pip_bin), 'install', '-r', str(install_file)], check=True) + # --only-binary for pywhispercpp: fail loudly if PyPI lacks a wheel for + # this interpreter rather than silently attempting a source build. + run_command([str(pip_bin), 'install', '--only-binary=pywhispercpp', '-r', str(install_file)], check=True) + + # pip can exit 0 without pywhispercpp present (e.g. filtered out of + # requirements); confirm the backend is importable before claiming success. + venv_python = pip_bin.parent / 'python' + verify = run_command([str(venv_python), '-c', 'import pywhispercpp'], check=False, capture_output=True) + if verify.returncode != 0: + log_error("pip succeeded but 'import pywhispercpp' failed — backend not installed") + return False + log_success("pywhispercpp installed (CPU-only mode)") set_state("installed_backend", "cpu") return True diff --git a/tests/test_wheel_pipeline_and_defaults.py b/tests/test_wheel_pipeline_and_defaults.py new file mode 100644 index 00000000..4abbedc3 --- /dev/null +++ b/tests/test_wheel_pipeline_and_defaults.py @@ -0,0 +1,78 @@ +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "lib")) +sys.path.insert(0, str(ROOT / "lib" / "src")) + +import backend_installer # noqa: E402 +import config_manager # noqa: E402 + + +class WheelVariantTests(unittest.TestCase): + def test_no_cuda_returns_none(self): + self.assertIsNone(backend_installer._get_wheel_variant(None)) + self.assertIsNone(backend_installer._get_wheel_variant("")) + + def test_cuda12_minors_all_map_to_one_wheel(self): + for v in ("12.0", "12.4", "12.6", "12.9"): + self.assertEqual(backend_installer._get_wheel_variant(v), "cuda12") + + def test_cuda11_and_13_fall_back_to_source(self): + for v in ("10.2", "11.8", "13.0"): + self.assertIsNone(backend_installer._get_wheel_variant(v)) + + +class WheelFilenameTests(unittest.TestCase): + def test_download_and_pip_names_per_python(self): + ver = backend_installer.PYWHISPERCPP_VERSION + for py in ("3.10", "3.11", "3.12", "3.13", "3.14"): + tag = "cp" + py.replace(".", "") + base = f"pywhispercpp-{ver}-{tag}-{tag}-linux_x86_64" + self.assertEqual( + backend_installer._get_wheel_filename(py, "cuda12", True), + f"{base}+cuda12.whl", + ) + self.assertEqual( + backend_installer._get_wheel_filename(py, "cuda12", False), + f"{base}.whl", + ) + + +class ConfigDefaultsTests(unittest.TestCase): + def _manager_for(self, cfg_dir, seed_file=None): + cfg_file = Path(cfg_dir) / "config.json" + if seed_file is not None: + cfg_file.write_text(json.dumps(seed_file)) + with mock.patch.object(config_manager, "CONFIG_DIR", Path(cfg_dir)), \ + mock.patch.object(config_manager, "CONFIG_FILE", cfg_file): + return config_manager.ConfigManager(verbose=False) + + def test_threads_default_is_capped_cpu_count(self): + with tempfile.TemporaryDirectory() as tmp: + cm = self._manager_for(tmp) + self.assertEqual(cm.get_setting("threads"), min(8, os.cpu_count() or 4)) + + def test_word_overrides_default_seeds_product_name(self): + with tempfile.TemporaryDirectory() as tmp: + cm = self._manager_for(tmp) + self.assertEqual(cm.get_word_overrides(), {"hyper whisper": "hyprwhspr"}) + + def test_existing_user_overrides_are_not_clobbered(self): + # A user with their own overrides keeps exactly theirs; the product seed + # is not merged in (config load is a top-level dict replace). + with tempfile.TemporaryDirectory() as tmp: + cm = self._manager_for( + tmp, + seed_file={"$schema": "x", "word_overrides": {"foo": "bar"}}, + ) + self.assertEqual(cm.get_word_overrides(), {"foo": "bar"}) + + +if __name__ == "__main__": + unittest.main()