diff --git a/apps/desktop/assets/windows/vc-runtime/x64/concrt140.dll b/apps/desktop/assets/windows/vc-runtime/x64/concrt140.dll new file mode 100644 index 0000000..ec4000d Binary files /dev/null and b/apps/desktop/assets/windows/vc-runtime/x64/concrt140.dll differ diff --git a/apps/desktop/assets/windows/vc-runtime/x64/manifest.json b/apps/desktop/assets/windows/vc-runtime/x64/manifest.json new file mode 100644 index 0000000..549c8c8 --- /dev/null +++ b/apps/desktop/assets/windows/vc-runtime/x64/manifest.json @@ -0,0 +1,51 @@ +{ + "schemaVersion": 1, + "product": "Microsoft Visual C++ 2015-2022 Redistributable (VC143 CRT)", + "version": "14.44.35211.0", + "architecture": "x64", + "publisher": "Microsoft Corporation (Authenticode doğrulandı)", + "files": [ + { + "name": "msvcp140.dll", + "size": 557728, + "sha256": "0f885b509a685d2bbfa652fed26b5fb31d88fbdab0a978c641d1c7b8aa460aa9" + }, + { + "name": "msvcp140_1.dll", + "size": 35952, + "sha256": "bfad5aef4c63a669e3c140655cdfdf395b6c979b400a447bd5dcb65ed8826c3d" + }, + { + "name": "msvcp140_2.dll", + "size": 280200, + "sha256": "3ea06f0ee098b4823cb79599df3780e7f23cce52c19aac31d2a0d47efe33a5e9" + }, + { + "name": "msvcp140_atomic_wait.dll", + "size": 50304, + "sha256": "640b2aefced484d0368eea5bdd06addd0658a3a70a49256e560d6923b404a479" + }, + { + "name": "msvcp140_codecvt_ids.dll", + "size": 31872, + "sha256": "f2069a52880ec885ee7f0511186100eb7fada0411a2b4948fafea7735b878a18" + }, + { + "name": "concrt140.dll", + "size": 324208, + "sha256": "2405355f0a58067b258f8df33c327e3a3d716eaac5a3a5aebb757842d85bd376" + }, + { + "name": "vcruntime140.dll", + "size": 124544, + "sha256": "d5e4d9a3e835fa679450145d6a7d94e36573a509317111904d9b3712c30d9066", + "ownedByPython": true + }, + { + "name": "vcruntime140_1.dll", + "size": 49792, + "sha256": "1f2d41c4aa5db0bc33ebf7b66d72943a817d7ce6cbe880502a9403823633093f", + "ownedByPython": true + } + ] +} diff --git a/apps/desktop/assets/windows/vc-runtime/x64/msvcp140.dll b/apps/desktop/assets/windows/vc-runtime/x64/msvcp140.dll new file mode 100644 index 0000000..bea9c37 Binary files /dev/null and b/apps/desktop/assets/windows/vc-runtime/x64/msvcp140.dll differ diff --git a/apps/desktop/assets/windows/vc-runtime/x64/msvcp140_1.dll b/apps/desktop/assets/windows/vc-runtime/x64/msvcp140_1.dll new file mode 100644 index 0000000..e1913ef Binary files /dev/null and b/apps/desktop/assets/windows/vc-runtime/x64/msvcp140_1.dll differ diff --git a/apps/desktop/assets/windows/vc-runtime/x64/msvcp140_2.dll b/apps/desktop/assets/windows/vc-runtime/x64/msvcp140_2.dll new file mode 100644 index 0000000..de91706 Binary files /dev/null and b/apps/desktop/assets/windows/vc-runtime/x64/msvcp140_2.dll differ diff --git a/apps/desktop/assets/windows/vc-runtime/x64/msvcp140_atomic_wait.dll b/apps/desktop/assets/windows/vc-runtime/x64/msvcp140_atomic_wait.dll new file mode 100644 index 0000000..077981c Binary files /dev/null and b/apps/desktop/assets/windows/vc-runtime/x64/msvcp140_atomic_wait.dll differ diff --git a/apps/desktop/assets/windows/vc-runtime/x64/msvcp140_codecvt_ids.dll b/apps/desktop/assets/windows/vc-runtime/x64/msvcp140_codecvt_ids.dll new file mode 100644 index 0000000..6492087 Binary files /dev/null and b/apps/desktop/assets/windows/vc-runtime/x64/msvcp140_codecvt_ids.dll differ diff --git a/apps/desktop/assets/windows/vc-runtime/x64/vcruntime140.dll b/apps/desktop/assets/windows/vc-runtime/x64/vcruntime140.dll new file mode 100644 index 0000000..5786e93 Binary files /dev/null and b/apps/desktop/assets/windows/vc-runtime/x64/vcruntime140.dll differ diff --git a/apps/desktop/assets/windows/vc-runtime/x64/vcruntime140_1.dll b/apps/desktop/assets/windows/vc-runtime/x64/vcruntime140_1.dll new file mode 100644 index 0000000..0b660f9 Binary files /dev/null and b/apps/desktop/assets/windows/vc-runtime/x64/vcruntime140_1.dll differ diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index b99683b..e238d13 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -33,6 +33,7 @@ import { } from 'electron' import { classifyActiveRuntime, needsPackagedRuntimeUpgrade } from './active-runtime-state' +import { describeVcRuntimeResult, ensureWindowsVcRuntime } from './windows-vc-runtime' import { destroyKeepaliveAgents, downloadAgentFor, jsonAgentFor, withRetry } from './api-transport' import { appIconCandidates, resolveAppIcon } from './app-icon' import { stopBackendChild as stopBackendChildImpl, stopBackendTreesForUpdate } from './backend-child' @@ -5221,6 +5222,22 @@ async function ensureRuntime(backend) { ) } + // Voice wheels (onnxruntime, ctranslate2) need msvcp140*.dll, which the + // managed CPython does not ship. Place the bundled Microsoft CRT beside the + // managed python.exe before the backend imports anything. Never blocks boot: + // only the voice features depend on it. + if (IS_WINDOWS) { + const vcRuntime = ensureWindowsVcRuntime({ + platform: process.platform, + venvRoot: VENV_ROOT, + sourceDir: IS_PACKAGED + ? path.join(process.resourcesPath, 'vc-runtime', 'x64') + : path.join(APP_ROOT, 'assets', 'windows', 'vc-runtime', 'x64') + }) + + rememberLog(`[vc-runtime] ${describeVcRuntimeResult(vcRuntime)}`) + } + backend.command = getVenvPython(VENV_ROOT) backend.label = `Hermes at ${ACTIVE_HERMES_ROOT} (venv: ${VENV_ROOT})` updateBootProgress({ diff --git a/apps/desktop/electron/windows-vc-runtime.test.ts b/apps/desktop/electron/windows-vc-runtime.test.ts new file mode 100644 index 0000000..aa59697 --- /dev/null +++ b/apps/desktop/electron/windows-vc-runtime.test.ts @@ -0,0 +1,171 @@ +import assert from 'node:assert/strict' +import crypto from 'node:crypto' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { afterEach, test } from 'vitest' + +import { ensureWindowsVcRuntime, loadVcRuntimeManifest, readPythonHome } from './windows-vc-runtime' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const roots: string[] = [] + +function tempRoot() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'aiturk-vc-runtime-')) + roots.push(root) + + return root +} + +function sha256(data: Buffer) { + return crypto.createHash('sha256').update(data).digest('hex') +} + +// A fake install: venv/pyvenv.cfg -> python home, plus a bundled source dir +// whose manifest describes the given files. +function fixture(files: Record) { + const root = tempRoot() + const home = path.join(root, 'python home') + const venv = path.join(root, 'venv') + const source = path.join(root, 'bundle') + + for (const dir of [home, venv, source]) { + fs.mkdirSync(dir, { recursive: true }) + } + + fs.writeFileSync(path.join(home, 'python.exe'), 'exe') + fs.writeFileSync(path.join(venv, 'pyvenv.cfg'), `home = ${home}\r\nversion_info = 3.11\r\n`) + + const entries = Object.entries(files).map(([name, { data, ownedByPython }]) => { + const bytes = Buffer.from(data) + fs.writeFileSync(path.join(source, name), bytes) + + return { name, size: bytes.length, sha256: sha256(bytes), ...(ownedByPython ? { ownedByPython } : {}) } + }) + + fs.writeFileSync( + path.join(source, 'manifest.json'), + JSON.stringify({ schemaVersion: 1, version: 'test', architecture: 'x64', files: entries }) + ) + + return { home, venv, source } +} + +afterEach(() => { + for (const root of roots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + +test('non-Windows platforms are left untouched', () => { + const { home, venv, source } = fixture({ 'msvcp140.dll': { data: 'crt' } }) + + assert.deepEqual(ensureWindowsVcRuntime({ platform: 'linux', venvRoot: venv, sourceDir: source }), { + status: 'skipped', + reason: 'not windows' + }) + assert.equal(fs.existsSync(path.join(home, 'msvcp140.dll')), false) +}) + +test('readPythonHome parses pyvenv.cfg home with spaces', () => { + const { home, venv } = fixture({ 'msvcp140.dll': { data: 'crt' } }) + + assert.equal(readPythonHome(venv), home) + assert.equal(readPythonHome(path.join(venv, 'missing')), null) +}) + +test('missing CRT files are copied beside python.exe and verified', () => { + const { home, venv, source } = fixture({ + 'msvcp140.dll': { data: 'msvcp' }, + 'msvcp140_1.dll': { data: 'msvcp1' } + }) + + const result = ensureWindowsVcRuntime({ platform: 'win32', venvRoot: venv, sourceDir: source }) + + assert.deepEqual(result, { status: 'repaired', pythonHome: home, copied: ['msvcp140.dll', 'msvcp140_1.dll'] }) + assert.equal(fs.readFileSync(path.join(home, 'msvcp140_1.dll'), 'utf8'), 'msvcp1') + assert.deepEqual(fs.readdirSync(home).filter(name => name.endsWith('.tmp')), []) + // Second launch: nothing to do. + assert.deepEqual(ensureWindowsVcRuntime({ platform: 'win32', venvRoot: venv, sourceDir: source }), { + status: 'ok', + pythonHome: home, + copied: [] + }) +}) + +test('a mismatching msvcp140 copy is replaced, a python-owned vcruntime is kept', () => { + const { home, venv, source } = fixture({ + 'msvcp140.dll': { data: 'good' }, + 'vcruntime140.dll': { data: 'bundled', ownedByPython: true } + }) + + fs.writeFileSync(path.join(home, 'msvcp140.dll'), 'stale') + fs.writeFileSync(path.join(home, 'vcruntime140.dll'), 'interpreter-owned') + + const result = ensureWindowsVcRuntime({ platform: 'win32', venvRoot: venv, sourceDir: source }) + + assert.deepEqual(result, { status: 'repaired', pythonHome: home, copied: ['msvcp140.dll'] }) + assert.equal(fs.readFileSync(path.join(home, 'msvcp140.dll'), 'utf8'), 'good') + assert.equal(fs.readFileSync(path.join(home, 'vcruntime140.dll'), 'utf8'), 'interpreter-owned') +}) + +test('a tampered bundled file is refused and nothing is written', () => { + const { home, venv, source } = fixture({ 'msvcp140.dll': { data: 'genuine' } }) + + fs.writeFileSync(path.join(source, 'msvcp140.dll'), 'tampered') + + const result = ensureWindowsVcRuntime({ platform: 'win32', venvRoot: venv, sourceDir: source }) + + assert.equal(result.status, 'failed') + assert.match((result as { error: string }).error, /does not match its manifest digest/) + assert.equal(fs.existsSync(path.join(home, 'msvcp140.dll')), false) +}) + +test('an unresolvable python home fails without throwing', () => { + const { venv, source } = fixture({ 'msvcp140.dll': { data: 'crt' } }) + + fs.writeFileSync(path.join(venv, 'pyvenv.cfg'), 'home = C:\\does-not-exist\n') + + assert.equal(ensureWindowsVcRuntime({ platform: 'win32', venvRoot: venv, sourceDir: source }).status, 'failed') +}) + +test('the shipped CRT set matches its manifest and covers the voice wheels', () => { + const sourceDir = path.join(here, '..', 'assets', 'windows', 'vc-runtime', 'x64') + const manifest = loadVcRuntimeManifest(sourceDir) + const names = manifest.files.map(file => file.name) + + for (const required of ['msvcp140.dll', 'msvcp140_1.dll', 'vcruntime140.dll', 'vcruntime140_1.dll']) { + assert.ok(names.includes(required), `${required} must be bundled`) + } + + for (const file of manifest.files) { + const bytes = fs.readFileSync(path.join(sourceDir, file.name)) + + assert.equal(bytes.length, file.size, file.name) + assert.equal(sha256(bytes), file.sha256, file.name) + } +}) + +test('the Windows package ships the CRT set as an extra resource', () => { + const pkg = JSON.parse(fs.readFileSync(path.join(here, '..', 'package.json'), 'utf8')) + const resources = pkg.build.win.extraResources + + assert.ok( + resources.some(entry => entry.from === 'assets/windows/vc-runtime/x64' && entry.to === 'vc-runtime/x64'), + 'win.extraResources must carry vc-runtime/x64' + ) +}) + +test('ensureRuntime places the CRT before handing the venv python to the backend', () => { + const source = fs.readFileSync(path.join(here, 'main.ts'), 'utf8') + const start = source.indexOf('async function ensureRuntime(') + const body = source.slice(start, source.indexOf('\nfunction ', start + 1)) + + const ensureIdx = body.indexOf('ensureWindowsVcRuntime(') + const commandIdx = body.indexOf('backend.command = getVenvPython(VENV_ROOT)') + + assert.notEqual(ensureIdx, -1, 'ensureRuntime must call ensureWindowsVcRuntime') + assert.ok(ensureIdx < commandIdx, 'CRT must be in place before the backend command is chosen') +}) diff --git a/apps/desktop/electron/windows-vc-runtime.ts b/apps/desktop/electron/windows-vc-runtime.ts new file mode 100644 index 0000000..445a447 --- /dev/null +++ b/apps/desktop/electron/windows-vc-runtime.ts @@ -0,0 +1,168 @@ +import crypto from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' + +// ── Windows VC++ runtime for the managed Python ───────────────────────────── +// +// Voice features load native wheels (onnxruntime for the wake word, +// ctranslate2 for local speech-to-text) that link against msvcp140.dll and +// msvcp140_1.dll. The managed CPython only ships vcruntime140*.dll, so on a +// Windows install without the system-wide VC++ Redistributable those imports +// fail with "DLL load failed / The specified module could not be found". +// +// The desktop package carries the Microsoft-signed VC143 CRT set and copies it +// next to the managed python.exe before every local backend start. That +// directory is the process image directory, which Windows searches first for +// extension-module dependencies, so a stale msvcp140.dll elsewhere on PATH can +// never win. Existing installs are healed on their next launch. + +export interface VcRuntimeFile { + name: string + size: number + sha256: string + // Files the managed CPython already ships. Never overwrite an existing copy: + // the interpreter owns it and replacing a loaded DLL is not possible anyway. + ownedByPython?: boolean +} + +export interface VcRuntimeManifest { + schemaVersion: 1 + version: string + architecture: string + files: VcRuntimeFile[] +} + +export type VcRuntimeResult = + | { status: 'skipped'; reason: string } + | { status: 'ok' | 'repaired'; pythonHome: string; copied: string[] } + | { status: 'failed'; pythonHome: string | null; copied: string[]; error: string } + +const FILE_NAME_RE = /^[a-z0-9_]+\.dll$/i +const SHA256_RE = /^[0-9a-f]{64}$/ + +export function readPythonHome(venvRoot: string): string | null { + let text: string + + try { + text = fs.readFileSync(path.join(venvRoot, 'pyvenv.cfg'), 'utf8') + } catch { + return null + } + + for (const line of text.split(/\r?\n/)) { + const match = /^\s*home\s*=\s*(.+?)\s*$/i.exec(line) + + if (match) { + return match[1] + } + } + + return null +} + +export function loadVcRuntimeManifest(sourceDir: string): VcRuntimeManifest { + const manifest = JSON.parse(fs.readFileSync(path.join(sourceDir, 'manifest.json'), 'utf8')) + + if (manifest?.schemaVersion !== 1 || !Array.isArray(manifest.files) || manifest.files.length === 0) { + throw new Error('invalid VC++ runtime manifest') + } + + for (const file of manifest.files) { + if (!FILE_NAME_RE.test(file?.name) || !SHA256_RE.test(file?.sha256) || !Number.isInteger(file?.size)) { + throw new Error(`invalid VC++ runtime manifest entry: ${JSON.stringify(file?.name)}`) + } + } + + return manifest +} + +function sha256File(filePath: string): string { + return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex') +} + +function matches(filePath: string, file: VcRuntimeFile): boolean { + try { + return fs.statSync(filePath).size === file.size && sha256File(filePath) === file.sha256 + } catch { + return false + } +} + +function installFile(sourceDir: string, pythonHome: string, file: VcRuntimeFile) { + const source = path.join(sourceDir, file.name) + + if (!matches(source, file)) { + throw new Error(`bundled ${file.name} does not match its manifest digest`) + } + + const target = path.join(pythonHome, file.name) + const temp = `${target}.aiturk-${process.pid}.tmp` + + try { + fs.copyFileSync(source, temp) + + if (!matches(temp, file)) { + throw new Error(`copied ${file.name} failed digest verification`) + } + + fs.renameSync(temp, target) + } finally { + fs.rmSync(temp, { force: true }) + } +} + +export function ensureWindowsVcRuntime({ + platform, + venvRoot, + sourceDir +}: { + platform: NodeJS.Platform + venvRoot: string + sourceDir: string +}): VcRuntimeResult { + if (platform !== 'win32') { + return { status: 'skipped', reason: 'not windows' } + } + + const pythonHome = readPythonHome(venvRoot) + + if (!pythonHome || !fs.existsSync(path.join(pythonHome, 'python.exe'))) { + return { status: 'failed', pythonHome, copied: [], error: 'managed python home not found' } + } + + const copied: string[] = [] + + try { + const manifest = loadVcRuntimeManifest(sourceDir) + + for (const file of manifest.files) { + const target = path.join(pythonHome, file.name) + + if (file.ownedByPython ? fs.existsSync(target) : matches(target, file)) { + continue + } + + installFile(sourceDir, pythonHome, file) + copied.push(file.name) + } + } catch (error) { + return { status: 'failed', pythonHome, copied, error: error instanceof Error ? error.message : String(error) } + } + + return { status: copied.length ? 'repaired' : 'ok', pythonHome, copied } +} + +export function describeVcRuntimeResult(result: VcRuntimeResult): string { + if (result.status === 'skipped') { + return `skipped (${result.reason})` + } + + if (result.status === 'failed') { + return `failed for ${result.pythonHome ?? 'unknown python home'}: ${result.error}` + + (result.copied.length ? ` (copied before failure: ${result.copied.join(', ')})` : '') + } + + return result.status === 'repaired' + ? `installed ${result.copied.join(', ')} into ${result.pythonHome}` + : `verified in ${result.pythonHome}` +} diff --git a/apps/desktop/package.json b/apps/desktop/package.json index de2f854..1159052 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -2,7 +2,7 @@ "name": "aiturk-ide", "productName": "AITURK IDE", "private": true, - "version": "1.0.0-beta.11", + "version": "1.0.0-beta.12", "description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.", "author": "AITURK / TurkServis", "repository": { @@ -290,7 +290,17 @@ "nsis", "msi" ], - "signAndEditExecutable": false + "signAndEditExecutable": false, + "extraResources": [ + { + "from": "assets/windows/vc-runtime/x64", + "to": "vc-runtime/x64", + "filter": [ + "*.dll", + "manifest.json" + ] + } + ] }, "linux": { "category": "Development", diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 91c2609..9a2f47b 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -1474,7 +1474,27 @@ def run_doctor(args): check_ok(name, "(optional)") except ImportError: check_warn(name, "(optional, not installed)") - + + if sys.platform == "win32": + # Voice wheels can be installed yet unloadable when the Microsoft VC++ + # runtime is missing; package presence alone hides that failure. + from tools.windows_native_runtime import native_load_error + + for module, name in ( + ("onnxruntime", "ONNX Runtime (wake word)"), + ("ctranslate2", "CTranslate2 (local speech-to-text)"), + ): + try: + installed = importlib.util.find_spec(module) is not None + except (ImportError, ValueError): + installed = False + if not installed: + continue + if native_load_error(module) is None: + check_ok(name, "(native runtime loads)") + else: + check_warn(name, "(Microsoft VC++ runtime missing - restart AITURK IDE to restore it)") + _section("Configuration Files") # Managed scope (administrator-pinned config/env), when present. managed_scope_check() diff --git a/package-lock.json b/package-lock.json index cbe9033..fc8c796 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,7 +65,7 @@ }, "apps/desktop": { "name": "aiturk-ide", - "version": "1.0.0-beta.11", + "version": "1.0.0-beta.12", "license": "MIT", "dependencies": { "@assistant-ui/core": "0.2.23", diff --git a/tests/conftest.py b/tests/conftest.py index e4dfb0e..17a3076 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -625,6 +625,19 @@ def _neutralize_webbrowser(monkeypatch): return opened +@pytest.fixture(autouse=True) +def _neutralize_native_runtime_probe(monkeypatch): + """Report native voice wheels as loadable unless a test says otherwise. + + The real probe imports onnxruntime/ctranslate2 in a child interpreter, so + its answer depends on the host's VC++ runtime rather than the code under + test. tests/tools/test_windows_native_runtime.py exercises the real probe. + """ + import tools.windows_native_runtime as _native_runtime + + monkeypatch.setattr(_native_runtime, "native_load_error", lambda module: None) + + @pytest.fixture(autouse=True) def _neutralize_macos_keychain_creds(request, monkeypatch): """Default Anthropic credential resolution away from the real macOS Keychain.""" diff --git a/tests/tools/test_windows_native_runtime.py b/tests/tools/test_windows_native_runtime.py new file mode 100644 index 0000000..f693037 --- /dev/null +++ b/tests/tools/test_windows_native_runtime.py @@ -0,0 +1,129 @@ +"""Windows VC++ runtime detection for the native voice wheels.""" + +import subprocess + +import pytest + +import tools.transcription_tools as tt +import tools.wake_word as ww +from tools import windows_native_runtime as wnr + +_NATIVE_LOAD_ERROR = wnr.native_load_error + + +@pytest.fixture(autouse=True) +def _clear_probe_cache(): + _NATIVE_LOAD_ERROR.cache_clear() + yield + _NATIVE_LOAD_ERROR.cache_clear() + + +def _fake_run(returncode, stderr, calls=None): + def run(cmd, **kwargs): + if calls is not None: + calls.append((cmd, kwargs)) + return subprocess.CompletedProcess(cmd, returncode, stdout="", stderr=stderr) + + return run + + +def test_non_windows_never_probes(monkeypatch): + monkeypatch.setattr(wnr.sys, "platform", "linux") + monkeypatch.setattr(wnr.subprocess, "run", pytest.fail) + + assert _NATIVE_LOAD_ERROR("onnxruntime") is None + + +@pytest.mark.parametrize( + "stderr", + [ + "ImportError: DLL load failed while importing onnxruntime_pybind11_state: The specified module could not be found.", + "ImportError: DLL load failed while importing onnxruntime_pybind11_state: Belirtilen modül bulunamadı.", + "FileNotFoundError: Could not find module 'C:\\venv\\ctranslate2\\ctranslate2.dll' (or one of its dependencies).", + ], +) +def test_dll_load_failure_is_reported(monkeypatch, stderr): + calls = [] + monkeypatch.setattr(wnr.sys, "platform", "win32") + monkeypatch.setattr(wnr.subprocess, "run", _fake_run(1, "Traceback (most recent call last):\n" + stderr, calls)) + + assert _NATIVE_LOAD_ERROR("onnxruntime") == stderr + assert calls[0][0][1:] == ["-c", "import onnxruntime"] + assert calls[0][1]["timeout"] > 0 + + +def test_missing_package_is_not_a_runtime_failure(monkeypatch): + monkeypatch.setattr(wnr.sys, "platform", "win32") + monkeypatch.setattr( + wnr.subprocess, "run", _fake_run(1, "ModuleNotFoundError: No module named 'onnxruntime'") + ) + + assert _NATIVE_LOAD_ERROR("onnxruntime") is None + + +def test_probe_result_is_cached_per_process(monkeypatch): + calls = [] + monkeypatch.setattr(wnr.sys, "platform", "win32") + monkeypatch.setattr(wnr.subprocess, "run", _fake_run(0, "", calls)) + + assert _NATIVE_LOAD_ERROR("ctranslate2") is None + assert _NATIVE_LOAD_ERROR("ctranslate2") is None + assert len(calls) == 1 + + +def test_inconclusive_probe_does_not_block(monkeypatch): + def timeout(cmd, **kwargs): + raise subprocess.TimeoutExpired(cmd, kwargs["timeout"]) + + monkeypatch.setattr(wnr.sys, "platform", "win32") + monkeypatch.setattr(wnr.subprocess, "run", timeout) + + assert _NATIVE_LOAD_ERROR("onnxruntime") is None + + +def test_module_name_is_validated(monkeypatch): + monkeypatch.setattr(wnr.sys, "platform", "win32") + + with pytest.raises(ValueError): + _NATIVE_LOAD_ERROR("os; import shutil") + + +def test_wake_requirements_report_missing_vc_runtime(monkeypatch): + monkeypatch.setattr(ww, "_stt_ready", lambda: True) + monkeypatch.setattr(ww, "_tts_ready", lambda: True) + monkeypatch.setattr(ww, "_audio_available", lambda: True) + monkeypatch.setattr(ww, "_local_input_device_ready", lambda: True) + monkeypatch.setattr("tools.lazy_deps.is_available", lambda f: True) + monkeypatch.setattr("tools.lazy_deps._allow_lazy_installs", lambda: True) + monkeypatch.setattr(ww, "_native_runtime_error", lambda module: "ImportError: DLL load failed") + + reqs = ww.check_wake_word_requirements({"provider": "openwakeword", "capture": "local"}) + + assert reqs["available"] is False + assert reqs["native_runtime_error"] == "ImportError: DLL load failed" + assert "msvcp140.dll" in reqs["hint"] + + +def test_wake_requirements_unaffected_when_runtime_loads(monkeypatch): + monkeypatch.setattr(ww, "_stt_ready", lambda: True) + monkeypatch.setattr(ww, "_tts_ready", lambda: True) + monkeypatch.setattr(ww, "_audio_available", lambda: True) + monkeypatch.setattr(ww, "_local_input_device_ready", lambda: True) + monkeypatch.setattr("tools.lazy_deps.is_available", lambda f: True) + monkeypatch.setattr(ww, "_native_runtime_error", lambda module: None) + + reqs = ww.check_wake_word_requirements({"provider": "openwakeword", "capture": "local"}) + + assert reqs["available"] is True + assert reqs["native_runtime_error"] is None + + +def test_local_transcription_reports_missing_vc_runtime(monkeypatch, tmp_path): + monkeypatch.setattr(tt, "_HAS_FASTER_WHISPER", True) + monkeypatch.setattr(wnr, "native_load_error", lambda module: "ImportError: DLL load failed") + monkeypatch.setattr(tt, "_load_stt_config", pytest.fail) + + result = tt._transcribe_local(str(tmp_path / "a.wav"), "base") + + assert result["success"] is False + assert "msvcp140.dll" in result["error"] diff --git a/tools/transcription_tools.py b/tools/transcription_tools.py index 53fa074..f2ee7fa 100644 --- a/tools/transcription_tools.py +++ b/tools/transcription_tools.py @@ -1942,6 +1942,14 @@ def _transcribe_local( if not _try_lazy_install_stt(): return {"success": False, "transcript": "", "error": "faster-whisper not installed"} + # faster-whisper imports lazily, so an unloadable ctranslate2 (missing + # VC++ runtime on Windows) would otherwise surface as an opaque DLL error. + from tools.windows_native_runtime import native_load_error, voice_runtime_error_message + + native_error = native_load_error("ctranslate2") + if native_error: + return {"success": False, "transcript": "", "error": voice_runtime_error_message(native_error)} + try: local_cfg = _load_stt_config().get("local") or {} # Reset the idle timer BEFORE loading/transcribing so the idle-unload diff --git a/tools/wake_word.py b/tools/wake_word.py index f433dc6..dc660a0 100644 --- a/tools/wake_word.py +++ b/tools/wake_word.py @@ -896,6 +896,12 @@ def _tts_ready() -> bool: return False +def _native_runtime_error(module: str) -> Optional[str]: + from tools.windows_native_runtime import native_load_error + + return native_load_error(module) + + def check_wake_word_requirements(cfg: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Report whether wake-word detection can run, with a remediation hint.""" cfg = cfg if cfg is not None else load_wake_word_config() @@ -910,6 +916,10 @@ def check_wake_word_requirements(cfg: Optional[Dict[str, Any]] = None) -> Dict[s feature = "wake.openwakeword" deps_ok = lazy_deps.is_available(feature) lazy_ok = lazy_deps._allow_lazy_installs() + # Installed metadata does not prove the native wheel loads: on Windows + # without the VC++ runtime onnxruntime is "installed" yet every import + # fails. Report that instead of a lazy install that cannot help. + native_error = _native_runtime_error("onnxruntime") if deps_ok and feature == "wake.openwakeword" else None # The audio probe imports sounddevice + numpy — two of the very packages # the lazy installer would fetch — so it can only be trusted once the # feature's deps are installed. On a fresh install (deps missing, lazy @@ -937,6 +947,10 @@ def check_wake_word_requirements(cfg: Optional[Dict[str, Any]] = None) -> Dict[s if provider == "porcupine" and not (os.getenv("PORCUPINE_ACCESS_KEY") or "").strip(): key_ok = False hint = "Set PORCUPINE_ACCESS_KEY (free key at https://console.picovoice.ai)." + elif native_error: + from tools.windows_native_runtime import voice_runtime_error_message + + hint = voice_runtime_error_message(native_error) elif not deps_ok and not lazy_ok: hint = lazy_deps.feature_install_command(feature) or "" elif not tflite_ok: @@ -969,7 +983,7 @@ def check_wake_word_requirements(cfg: Optional[Dict[str, Any]] = None) -> Dict[s ) return { - "available": key_ok and stt_ok and tts_ok and tflite_ok and mic_ok, + "available": key_ok and stt_ok and tts_ok and tflite_ok and mic_ok and native_error is None, "provider": provider, "deps_available": deps_ok, "audio_available": audio_ok, @@ -979,6 +993,7 @@ def check_wake_word_requirements(cfg: Optional[Dict[str, Any]] = None) -> Dict[s "stt_available": stt_ok, "tts_available": tts_ok, "phrase": wake_phrase(cfg), + "native_runtime_error": native_error, "hint": hint, } diff --git a/tools/windows_native_runtime.py b/tools/windows_native_runtime.py new file mode 100644 index 0000000..3c6d982 --- /dev/null +++ b/tools/windows_native_runtime.py @@ -0,0 +1,77 @@ +"""Detect native voice wheels that are installed but cannot load on Windows. + +Package metadata (what ``tools.lazy_deps`` checks) reports onnxruntime and +ctranslate2 as installed even when their DLLs cannot load because the +Microsoft Visual C++ runtime (msvcp140.dll) is missing. A lazy reinstall cannot +fix that, so callers need to tell the two cases apart. Importing the module in +a child interpreter does so without risking the calling process. +""" + +from __future__ import annotations + +import functools +import os +import re +import subprocess +import sys +from typing import Optional + +_MODULE_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_.]*$") + +# Windows reports the same loader failure in the user's UI language. +_DLL_LOAD_MARKERS = ( + "dll load failed", + "could not find module", + "specified module could not be found", + "belirtilen modül bulunamadı", + "[winerror 126]", +) + +VC_RUNTIME_HINT = ( + "Ses bileşeni yüklenemedi: Microsoft Visual C++ çalışma zamanı (msvcp140.dll) eksik. " + "AITURK IDE'yi kapatıp yeniden açın; IDE eksik dosyaları otomatik olarak geri yükler." +) + + +def looks_like_dll_load_failure(message: str) -> bool: + text = (message or "").lower() + return any(marker in text for marker in _DLL_LOAD_MARKERS) + + +@functools.lru_cache(maxsize=None) +def native_load_error(module: str) -> Optional[str]: + """Return the DLL load error for *module* on Windows, otherwise None. + + None also covers "not installed" and inconclusive probes: this helper only + answers "installed but its native libraries cannot load". Cached per + process because the runtime cannot change under a running backend (the + desktop restores it before the backend starts). + """ + if sys.platform != "win32": + return None + if not _MODULE_RE.match(module): + raise ValueError(f"invalid module name: {module!r}") + + try: + proc = subprocess.run( + [sys.executable, "-c", f"import {module}"], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=120, + env=dict(os.environ, PYTHONIOENCODING="utf-8"), + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + except (OSError, subprocess.TimeoutExpired): + return None + + stderr = proc.stderr or "" + if proc.returncode == 0 or not looks_like_dll_load_failure(stderr): + return None + lines = [line.strip() for line in stderr.splitlines() if line.strip()] + return lines[-1] if lines else f"import {module} failed" + + +def voice_runtime_error_message(error: str) -> str: + return f"{VC_RUNTIME_HINT} ({error})"