diff --git a/LOCAL_WHISPER_SETUP.md b/LOCAL_WHISPER_SETUP.md
index 1d26d5be56..53f4bd1c5a 100644
--- a/LOCAL_WHISPER_SETUP.md
+++ b/LOCAL_WHISPER_SETUP.md
@@ -28,10 +28,11 @@ The first transcription will download the model automatically.
Local Whisper can run on your GPU for much faster transcription:
- **macOS**: Metal acceleration is built in — no setup needed on Apple Silicon
-- **NVIDIA (Windows/Linux)**: one-click CUDA runtime download from the GPU card in the transcription model picker
-- **AMD / Intel (Windows/Linux)**: one-click Vulkan runtime download from the same GPU card — covers Radeon and Arc/integrated GPUs
+- **NVIDIA (Windows/Linux)**: CUDA acceleration; Linux requires a current compatible NVIDIA driver, but not the CUDA Toolkit
+- **AMD / Intel (Windows/Linux)**: Vulkan acceleration for Radeon, Arc, and integrated GPUs
+- **NVIDIA alternative**: Vulkan is also supported and is used automatically when its runtime is installed and CUDA fails
-The GPU runtime is downloaded on demand with SHA-256-verified checksums. If the GPU server crashes or fails to start (unsupported GPU, out of VRAM), OpenWhispr automatically falls back to CPU transcription and shows a notice — dictation keeps working.
+GPU runtimes are downloaded on demand with SHA-256-verified checksums. If one fails, OpenWhispr uses the next available backend and finally CPU, so dictation keeps working.
## How It Works
@@ -45,7 +46,7 @@ OpenWhispr uses whisper.cpp, a high-performance C++ implementation of OpenAI's W
- **Disk Space**: 75MB–3GB depending on model
- **RAM**: 1GB–10GB depending on model
-- **No additional dependencies required** - whisper.cpp is bundled in packaged builds
+- **GPU driver**: required for GPU acceleration; the CUDA Toolkit is not required
## Running From Source
diff --git a/main.js b/main.js
index f087be23a9..8d6ab4ec1e 100644
--- a/main.js
+++ b/main.js
@@ -573,10 +573,40 @@ function initializeCoreManagers() {
// with no pack on disk only happens via such data loss. recordOnce gates
// each pack to one notice so a dismissed toast doesn't return every launch.
const gpuPacks = [
- { manager: whisperCudaManager, enabledEnvVar: "WHISPER_CUDA_ENABLED", group: "whisper" },
- { manager: whisperVulkanManager, enabledEnvVar: "WHISPER_VULKAN_ENABLED", group: "whisper" },
+ {
+ manager: whisperCudaManager,
+ enabledEnvVar: "WHISPER_CUDA_ENABLED",
+ group: "whisper",
+ backend: "cuda",
+ },
+ {
+ manager: whisperVulkanManager,
+ enabledEnvVar: "WHISPER_VULKAN_ENABLED",
+ group: "whisper",
+ backend: "vulkan",
+ },
{ manager: llamaVulkanManager, enabledEnvVar: "LLAMA_VULKAN_ENABLED" },
];
+ // A pack whose backend failed here and then vanished (a distro launcher
+ // deletes it, #2168) was no loss: drop its flag as a delete would, so no
+ // notice names it, even after an upgrade clears the remembered failure.
+ const failedBackends = WhisperManager.resolveFailedGpuBackends(process.env.WHISPER_GPU_FAILED);
+ for (const { manager, enabledEnvVar, backend } of gpuPacks) {
+ if (
+ failedBackends.includes(backend) &&
+ process.env[enabledEnvVar] === "true" &&
+ !manager.isDownloaded() &&
+ !manager.needsUpdate()
+ ) {
+ delete process.env[enabledEnvVar];
+ environmentManager.removeKeyFromEnvFile(enabledEnvVar).catch((err) => {
+ debugLogger.error("Failed to clear a vanished GPU pack's flag from .env", {
+ key: enabledEnvVar,
+ error: err.message,
+ });
+ });
+ }
+ }
const orphanedPacks = detectOrphanedGpuPacks(gpuPacks);
if (orphanedPacks.length > 0) {
require("./src/helpers/gpuPackMigrationNotice").recordOnce(orphanedPacks);
diff --git a/src/components/TranscriptionModelPicker.tsx b/src/components/TranscriptionModelPicker.tsx
index 8d5b1e57c1..bfc8096166 100644
--- a/src/components/TranscriptionModelPicker.tsx
+++ b/src/components/TranscriptionModelPicker.tsx
@@ -514,6 +514,7 @@ export default function TranscriptionModelPicker({
const [gpuActivating, setGpuActivating] = useState(false);
// Live truth from the running server; "active" is never inferred from a download
const [gpuActive, setGpuActive] = useState(false);
+ const gpuBackendName = gpuBackend === "cuda" ? "CUDA" : "Vulkan";
useEffect(() => {
const organization = getSelectedASROrganization(selectedLocalProvider, selectedLocalModel);
@@ -1623,21 +1624,21 @@ export default function TranscriptionModelPicker({
<>
- {t("gpu.activating")}
+ {t("gpu.activatingBackend", { backend: gpuBackendName })}
>
) : gpuActive ? (
<>
- {t("gpu.active")}
+ {t("gpu.activeBackend", { backend: gpuBackendName })}
>
) : (
<>
- {t("gpu.ready")}
+ {t("gpu.readyBackend", { backend: gpuBackendName })}
>
)}
@@ -1667,6 +1668,9 @@ export default function TranscriptionModelPicker({
{t("gpu.transcriptionBanner")}
+
+ {t(gpuBackend === "cuda" ? "gpu.cudaDescription" : "gpu.vulkanDescription")}
+
- {t("gpu.enableButton")}
+ {t("gpu.enableBackend", { backend: gpuBackendName })}
setGpuDismissed(true)}
diff --git a/src/helpers/ipcHandlers.js b/src/helpers/ipcHandlers.js
index 61efd1e597..df284dae27 100644
--- a/src/helpers/ipcHandlers.js
+++ b/src/helpers/ipcHandlers.js
@@ -1409,13 +1409,23 @@ class IPCHandlers {
}
_attachWhisperServerListeners(serverManager) {
- serverManager.on("cuda-fallback", ({ reason }) => {
+ // Landing on the Vulkan pack or a bundled GPU build (#2168) keeps the GPU:
+ // no "switched to CPU" pop-up, but every GPU card re-reads to follow the server
+ serverManager.on("cuda-fallback", ({ reason, fallbackBackend }) => {
this._recordWhisperGpuFailure("cuda", reason);
- broadcastToWindows("cuda-fallback-notification", {});
+ if (fallbackBackend !== "cpu") {
+ broadcastToWindows("whisper-gpu-status-changed");
+ } else {
+ broadcastToWindows("cuda-fallback-notification", {});
+ }
});
- serverManager.on("gpu-fallback", ({ reason }) => {
+ serverManager.on("gpu-fallback", ({ reason, fallbackBackend }) => {
this._recordWhisperGpuFailure("vulkan", reason);
- broadcastToWindows("gpu-fallback-notification", {});
+ if (fallbackBackend !== "cpu") {
+ broadcastToWindows("whisper-gpu-status-changed");
+ } else {
+ broadcastToWindows("gpu-fallback-notification", {});
+ }
});
// Persist the discrete-GPU pin so later launches spawn pinned directly
// instead of paying a second Vulkan cold start. See #1606.
@@ -1425,6 +1435,17 @@ class IPCHandlers {
serverManager.on("vulkan-device-pin-cleared", () => {
this._syncStartupEnv({}, ["WHISPER_VULKAN_DEVICE"]);
});
+ // The GPU card and banner stop offering a pack (see _whisperBundledGpu)
+ serverManager.on("bundled-gpu", () => broadcastToWindows("whisper-gpu-status-changed"));
+ }
+
+ // A bundled whisper-server built for the GPU runs on it with no pack (#2168),
+ // so no pack is offered.
+ _whisperBundledGpu() {
+ const { serverManager } = this.whisperManager;
+ return (
+ !serverManager.useCuda && !serverManager.useVulkan && serverManager.getStatus().gpuAccelerated
+ );
}
// Remember the failed backend, and the error line that explains it, so the
@@ -1487,7 +1508,7 @@ class IPCHandlers {
// Every pack download or delete and every Retry ends here, already saved.
// Tell every window, not just the caller's: Retry on the fallback pop-up
// runs in the dictation window, and Settings keeps up to three GPU cards
- // mounted (#1736). A fallback is announced by its own notification.
+ // mounted (#1736). A fallback is announced where it is recorded.
broadcastToWindows("whisper-gpu-status-changed");
return !!modelName;
}
@@ -3713,6 +3734,7 @@ class IPCHandlers {
gpuInfo,
...this._whisperGpuFailureStatus("cuda"),
inUse: this.whisperManager.resolveGpuPackInUse() === "cuda",
+ bundledGpu: this._whisperBundledGpu(),
};
});
@@ -3777,6 +3799,7 @@ class IPCHandlers {
hasNvidiaGpu: gpuInfo.hasNvidiaGpu,
...this._whisperGpuFailureStatus("vulkan"),
inUse: this.whisperManager.resolveGpuPackInUse() === "vulkan",
+ bundledGpu: this._whisperBundledGpu(),
};
});
diff --git a/src/helpers/whisper.js b/src/helpers/whisper.js
index 48c058778f..577f5e4d99 100644
--- a/src/helpers/whisper.js
+++ b/src/helpers/whisper.js
@@ -131,12 +131,17 @@ class WhisperManager {
(process.env.WHISPER_CUDA_ENABLED || "").toLowerCase() !== "false" &&
!failed.includes("cuda") &&
!!this._cudaBinaryManager?.isDownloaded();
- const useVulkan =
- !useCuda &&
+ const vulkanAvailable =
(process.env.WHISPER_VULKAN_ENABLED || "").toLowerCase() !== "false" &&
!failed.includes("vulkan") &&
!!this._vulkanBinaryManager?.isDownloaded();
- return { useCuda, useVulkan };
+ // Always set fallbackToVulkan: callers merge this over the last start's
+ // options, so an omitted false would keep a stale true
+ return {
+ useCuda,
+ useVulkan: !useCuda && vulkanAvailable,
+ fallbackToVulkan: useCuda && vulkanAvailable,
+ };
}
// Why a backend the user wants can't be used, or null when it isn't wanted.
@@ -264,7 +269,8 @@ class WhisperManager {
// Pre-warm whisper-server if local mode enabled (eliminates 2-5s cold-start delay)
const { localTranscriptionProvider, whisperModel } = settings;
- const { useCuda, useVulkan } = this.resolveGpuStartOptions();
+ const gpuOptions = this.resolveGpuStartOptions();
+ const { useCuda, useVulkan } = gpuOptions;
if (
localTranscriptionProvider === "whisper" &&
@@ -284,7 +290,7 @@ class WhisperManager {
try {
const serverStartTime = Date.now();
- await this.serverManager.start(modelPath, { useCuda, useVulkan });
+ await this.serverManager.start(modelPath, gpuOptions);
this.currentServerModel = whisperModel;
debugLogger.info("whisper-server pre-warmed successfully", {
diff --git a/src/helpers/whisperCppRelease.js b/src/helpers/whisperCppRelease.js
index 4dd3caa839..0bf2ea7fe1 100644
--- a/src/helpers/whisperCppRelease.js
+++ b/src/helpers/whisperCppRelease.js
@@ -1,4 +1,4 @@
-const WHISPER_CPP_TAG = process.env.WHISPER_CPP_VERSION || "0.0.10";
+const WHISPER_CPP_TAG = process.env.WHISPER_CPP_VERSION || "0.0.11";
const WINDOWS_MSVC_RUNTIME_LIBRARIES = Object.freeze([
"msvcp140.dll",
diff --git a/src/helpers/whisperCudaManager.js b/src/helpers/whisperCudaManager.js
index 039b9332ab..0d74bccf2b 100644
--- a/src/helpers/whisperCudaManager.js
+++ b/src/helpers/whisperCudaManager.js
@@ -4,6 +4,13 @@ const { WHISPER_CPP_TAG, WINDOWS_MSVC_RUNTIME_LIBRARIES } = require("./whisperCp
// Pinned so untested future binaries never auto-ship; bump together with the digests below
// sha256 per release tag; tags without an entry fall back to the API-reported digest
const EXPECTED_DIGESTS = {
+ // Linux archive bundles the CUDA 12 runtime (cudart, cuBLAS, cuBLASLt)
+ "0.0.11": {
+ "whisper-server-win32-x64-cuda.zip":
+ "5d0ce15234aed4e30c075010c13dd23771fb5c714c48047e287306ab340eea9a",
+ "whisper-server-linux-x64-cuda.zip":
+ "702478df828069aca6e687b869b2f68d20105143328046cdb070551637b0b198",
+ },
// Adds app-local MSVC runtime DLLs to the Windows archive
"0.0.10": {
"whisper-server-win32-x64-cuda.zip":
@@ -48,6 +55,10 @@ class WhisperCudaManager extends GpuBinaryManager {
binaryName: "whisper-server-linux-x64-cuda",
outputName: "whisper-server-linux-x64-cuda",
libPattern: /\.so(\.\d+)*$/,
+ // The CUDA 12 runtime the binary links, so hosts with another CUDA major
+ // (or no toolkit) can run it; only the driver's libcuda.so.1 comes from
+ // the system. Packs from whisper.cpp 0.0.10 and older lack them.
+ requiredLibraries: ["libcudart.so.12", "libcublas.so.12", "libcublasLt.so.12"],
},
},
});
diff --git a/src/helpers/whisperServer.js b/src/helpers/whisperServer.js
index e789cf1624..0bc617ad26 100644
--- a/src/helpers/whisperServer.js
+++ b/src/helpers/whisperServer.js
@@ -149,6 +149,13 @@ function getGpuSignature(options = {}) {
return `gpu:vulkan:${Number.isInteger(deviceIndex) && deviceIndex >= 0 ? deviceIndex : "default"}`;
}
+function parseWhisperGpuBackend(output) {
+ const match = String(output || "").match(
+ /whisper_backend_init_gpu: using (CUDA|Vulkan)\d+ backend/i
+ );
+ return match ? match[1].toLowerCase() : null;
+}
+
function buildWhisperServerArgs({
modelPath,
port,
@@ -299,6 +306,7 @@ class WhisperServerManager extends EventEmitter {
this.canConvert = false;
this.useCuda = false;
this.useVulkan = false;
+ this.activeGpuBackend = null;
this.startGeneration = 0;
this._stopRequested = false;
this.vadSignature = "vad:off";
@@ -307,7 +315,7 @@ class WhisperServerManager extends EventEmitter {
this.gpuFallbackActive = false;
this.lastStartOptions = {};
// Output and exit of the last spawned server, read when a GPU server that
- // started fine dies mid-transcription (_fallbackToCpuAndRetry). See #1736.
+ // started fine dies mid-transcription (_fallbackAndRetry). See #1736.
this._lastProcessInfo = null;
}
@@ -515,12 +523,11 @@ class WhisperServerManager extends EventEmitter {
const nextThreadSignature = getThreadSignature(threadResolution);
const nextVadSignature = getVadSignature(options);
const nextGpuSignature = getGpuSignature(options);
- // gpuFallbackActive pins a fallback session to its working CPU server:
- // after a CUDA crash the next request can resolve to a different backend
- // (an installed Vulkan pack, since only the crashed backend is recorded in
- // WHISPER_GPU_FAILED) and must not tear the session down for a GPU cold
- // start mid-dictation. stop() clears the pin, so pack downloads, explicit
- // retries, and app restarts get a fresh GPU attempt.
+ // gpuFallbackActive pins a fallback session to its working CPU server: a
+ // later request that still resolves to a GPU backend must not tear the
+ // session down for a GPU cold start mid-dictation. stop() clears the pin,
+ // so pack downloads, explicit retries, and app restarts get a fresh GPU
+ // attempt.
if (
this.ready &&
this.modelPath === modelPath &&
@@ -564,6 +571,7 @@ class WhisperServerManager extends EventEmitter {
this.modelPath = modelPath;
this.useCuda = usingCuda;
this.useVulkan = usingVulkan;
+ this.activeGpuBackend = null;
// Pin Vulkan to a specific device (see resolveVulkanDeviceIndex; the
// one-shot restart below passes the explicit index).
@@ -590,9 +598,13 @@ class WhisperServerManager extends EventEmitter {
spawnEnv.TMP = safeTmp;
}
- // Add the whisper-server directory to PATH so any companion DLLs are found
const serverBinaryDir = path.dirname(serverBinary);
spawnEnv.PATH = serverBinaryDir + pathSep + (process.env.PATH || "");
+ if (process.platform === "linux" && usingCuda) {
+ spawnEnv.LD_LIBRARY_PATH = [serverBinaryDir, process.env.LD_LIBRARY_PATH]
+ .filter(Boolean)
+ .join(":");
+ }
// Select GPU by UUID + PCI_BUS_ID order so the device is unambiguous. See #531.
if (usingCuda) {
@@ -633,8 +645,6 @@ class WhisperServerManager extends EventEmitter {
threads: threadResolution,
});
- const startTime = Date.now();
-
this.process = spawn(serverBinary, args, {
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
@@ -670,34 +680,68 @@ class WhisperServerManager extends EventEmitter {
debugLogger.debug("whisper-server process exited", { code, signal });
this.ready = false;
this.process = null;
+ this.activeGpuBackend = null;
this.stopHealthCheck();
sidecarPidFile.clear("whisper");
});
const startupTimeoutMs = usingVulkan ? VULKAN_STARTUP_TIMEOUT_MS : STARTUP_TIMEOUT_MS;
+ let pinAction = { action: "none" };
try {
await this.waitForReady(getProcessInfo, startupTimeoutMs);
+ if (usingVulkan && !options.vulkanPinChecked) {
+ pinAction = resolveVulkanPinAction({
+ devices: parseVulkanDevices(stderrBuffer),
+ appliedPin: vulkanDeviceIndex,
+ });
+ }
+ const expectedBackend = usingCuda ? "cuda" : usingVulkan ? "vulkan" : null;
+ const activeBackend = parseWhisperGpuBackend(stderrBuffer);
+ // A pin restart below settles the device first: a pin to a device that
+ // is gone leaves the server without its GPU until the pin is cleared.
+ if (expectedBackend && activeBackend !== expectedBackend && pinAction.action === "none") {
+ const error = new Error("server started without initializing its GPU backend");
+ error.gpuBackendMissing = true;
+ throw error;
+ }
+ this.activeGpuBackend = activeBackend;
} catch (err) {
// An intentional stop() during startup is not a GPU/thread failure
if (err.isStopped) throw err;
if (usingCuda || usingVulkan) {
- // Fall back on ANY startup rejection — a GPU server can exit early
- // (missing kernels), die late (VRAM OOM mid-model-load), or hang, and
- // in every case the CPU binary is the working answer. stop() reaps a
- // hung process before the CPU restart. The reason travels with the
- // event so it is saved beside the failure and shown on the GPU card (#1736).
- const reason = extractWhisperGpuFailureReason({
- ...getProcessInfo(),
- timeoutMs: startupTimeoutMs,
- });
+ // A GPU server can exit early, die during model load, hang, or come up
+ // without its GPU. stop() reaps it before trying the next available
+ // backend. The reason travels with the event so it is saved beside the
+ // failure and shown on the GPU card (#1736). A server that came up on the
+ // CPU neither exited nor timed out: its log or the check itself says why.
+ // A CPU fallback is announced after the CPU start settles, failed or not:
+ // the bundled build can run on the GPU itself (#2168).
+ const reason = err.gpuBackendMissing
+ ? extractWhisperGpuFailureReason(getProcessInfo()) || err.message
+ : extractWhisperGpuFailureReason({ ...getProcessInfo(), timeoutMs: startupTimeoutMs });
+ const backend = usingCuda ? "cuda" : "vulkan";
+ const fallbackToVulkan = usingCuda && options.fallbackToVulkan === true;
debugLogger.warn(
- `${usingCuda ? "CUDA" : "Vulkan"} whisper-server failed, falling back to CPU`,
+ fallbackToVulkan
+ ? "CUDA whisper-server failed, trying Vulkan"
+ : `${backend} whisper-server failed, falling back to CPU`,
{ error: err.message, exitCode, reason }
);
- this.emit(usingCuda ? "cuda-fallback" : "gpu-fallback", { reason });
+ if (fallbackToVulkan) this.emit("cuda-fallback", { reason, fallbackBackend: "vulkan" });
await this.stop();
- this.gpuFallbackActive = true;
- return this._doStart(modelPath, { ...options, useCuda: false, useVulkan: false });
+ this.gpuFallbackActive = !fallbackToVulkan;
+ const fallbackOptions = { ...options, useCuda: false, useVulkan: fallbackToVulkan };
+ delete fallbackOptions.fallbackToVulkan;
+ try {
+ return await this._doStart(modelPath, fallbackOptions);
+ } finally {
+ if (!fallbackToVulkan) {
+ this.emit(backend === "cuda" ? "cuda-fallback" : "gpu-fallback", {
+ reason,
+ fallbackBackend: this.activeGpuBackend ?? "cpu",
+ });
+ }
+ }
}
if (shouldFallbackToDefaultThreads(threadResolution)) {
const defaultThreadResolution = createThreadResolution(
@@ -725,35 +769,29 @@ class WhisperServerManager extends EventEmitter {
// (and drop a persisted pin that no longer resolves). vulkanPinChecked
// bounds this to a single extra start; if the pinned restart fails, the
// catch above falls back to CPU as usual. See #1606.
- if (usingVulkan && !options.vulkanPinChecked) {
- const pinAction = resolveVulkanPinAction({
- devices: parseVulkanDevices(stderrBuffer),
+ if (pinAction.action === "pin") {
+ debugLogger.info("Vulkan device 0 is integrated; restarting pinned to discrete GPU", {
+ index: pinAction.index,
+ });
+ this.emit("vulkan-device-pinned", { index: pinAction.index });
+ await this.stop();
+ return this._doStart(modelPath, {
+ ...options,
+ vulkanDeviceIndex: pinAction.index,
+ vulkanPinChecked: true,
+ });
+ }
+ if (pinAction.action === "clear") {
+ debugLogger.warn("Persisted Vulkan device pin is out of range; clearing it", {
appliedPin: vulkanDeviceIndex,
});
- if (pinAction.action === "pin") {
- debugLogger.info("Vulkan device 0 is integrated; restarting pinned to discrete GPU", {
- index: pinAction.index,
- });
- this.emit("vulkan-device-pinned", { index: pinAction.index });
- await this.stop();
- return this._doStart(modelPath, {
- ...options,
- vulkanDeviceIndex: pinAction.index,
- vulkanPinChecked: true,
- });
- }
- if (pinAction.action === "clear") {
- debugLogger.warn("Persisted Vulkan device pin is out of range; clearing it", {
- appliedPin: vulkanDeviceIndex,
- });
- this.emit("vulkan-device-pin-cleared");
- await this.stop();
- return this._doStart(modelPath, {
- ...options,
- vulkanDeviceIndex: -1,
- vulkanPinChecked: true,
- });
- }
+ this.emit("vulkan-device-pin-cleared");
+ await this.stop();
+ return this._doStart(modelPath, {
+ ...options,
+ vulkanDeviceIndex: -1,
+ vulkanPinChecked: true,
+ });
}
this.startHealthCheck();
@@ -763,10 +801,15 @@ class WhisperServerManager extends EventEmitter {
model: path.basename(modelPath),
cuda: this.useCuda,
vulkan: this.useVulkan,
+ gpuBackend: this.activeGpuBackend,
threads: threadResolution.threads || DEFAULT_WHISPER_THREADS,
threadSource: threadResolution.source,
availableParallelism: threadResolution.availableParallelism,
});
+
+ // The bundled whisper-server can itself be a GPU build (a distro package
+ // that compiles it with CUDA, #2168): it runs on the GPU with no pack.
+ if (!usingCuda && !usingVulkan && this.activeGpuBackend) this.emit("bundled-gpu");
}
async waitForReady(getProcessInfo, timeoutMs = STARTUP_TIMEOUT_MS) {
@@ -1059,7 +1102,7 @@ class WhisperServerManager extends EventEmitter {
processExited,
})
) {
- return await this._fallbackToCpuAndRetry(body, boundary, modelPath, stderrMark);
+ return await this._fallbackAndRetry(body, boundary, modelPath, stderrMark);
}
if (this.startGeneration === generation) throw err;
}
@@ -1093,13 +1136,14 @@ class WhisperServerManager extends EventEmitter {
}
const exited = await this._waitForProcessExit(PROCESS_EXIT_WAIT_MS);
if (!exited || this._stopRequested) throw retryErr;
- return await this._fallbackToCpuAndRetry(body, boundary, modelPath, stderrMark);
+ return await this._fallbackAndRetry(body, boundary, modelPath, stderrMark);
}
}
- async _fallbackToCpuAndRetry(body, boundary, modelPath, stderrMark) {
+ async _fallbackAndRetry(body, boundary, modelPath, stderrMark) {
const backend = this.useCuda ? "cuda" : "vulkan";
- // Read the crashed server's output now (the CPU start below replaces it),
+ const fallbackToVulkan = backend === "cuda" && this.lastStartOptions.fallbackToVulkan === true;
+ // Read the crashed server's output now (the start below replaces it),
// from where the failing request began. A peer's replacement server is a
// different process, so its output is read whole.
const processInfo = this._lastProcessInfo();
@@ -1108,15 +1152,35 @@ class WhisperServerManager extends EventEmitter {
...processInfo,
stderr: processInfo.stderr.slice(offset),
});
- debugLogger.warn(`${backend} whisper-server died during transcription, falling back to CPU`, {
- port: this.port,
- model: modelPath ? path.basename(modelPath) : null,
- reason,
- });
- await this.start(modelPath, { ...this.lastStartOptions, useCuda: false, useVulkan: false });
- this.gpuFallbackActive = true;
- // Emit only once the CPU server is up — the notification tells the user CPU is in use
- this.emit(backend === "cuda" ? "cuda-fallback" : "gpu-fallback", { reason });
+ debugLogger.warn(
+ fallbackToVulkan
+ ? "CUDA whisper-server died during transcription, trying Vulkan"
+ : `${backend} whisper-server died during transcription, falling back to CPU`,
+ {
+ port: this.port,
+ model: modelPath ? path.basename(modelPath) : null,
+ reason,
+ }
+ );
+ if (fallbackToVulkan) {
+ this.emit("cuda-fallback", { reason, fallbackBackend: "vulkan" });
+ }
+ const fallbackOptions = {
+ ...this.lastStartOptions,
+ useCuda: false,
+ useVulkan: fallbackToVulkan,
+ };
+ delete fallbackOptions.fallbackToVulkan;
+ await this.start(modelPath, fallbackOptions);
+ this.gpuFallbackActive = !this.useVulkan;
+ // Emit only once the server is up: it tells whether CPU is in use, or a
+ // bundled GPU build of whisper-server (#2168)
+ if (!fallbackToVulkan) {
+ this.emit(backend === "cuda" ? "cuda-fallback" : "gpu-fallback", {
+ reason,
+ fallbackBackend: this.activeGpuBackend ?? "cpu",
+ });
+ }
return await this._postInference(body, boundary);
}
@@ -1153,6 +1217,7 @@ class WhisperServerManager extends EventEmitter {
async stop() {
this._stopRequested = true;
this.gpuFallbackActive = false;
+ this.activeGpuBackend = null;
this.stopHealthCheck();
if (this.isRemote) {
@@ -1204,7 +1269,7 @@ class WhisperServerManager extends EventEmitter {
getStatus() {
const running = this.ready && (this.process !== null || this.isRemote);
- const gpuBackend = this.useCuda ? "cuda" : this.useVulkan ? "vulkan" : null;
+ const gpuBackend = this.activeGpuBackend;
return {
available: this.isAvailable(),
running,
@@ -1228,6 +1293,7 @@ module.exports.parseVulkanDevices = parseVulkanDevices;
module.exports.resolveVulkanPinAction = resolveVulkanPinAction;
module.exports.getVadSignature = getVadSignature;
module.exports.getGpuSignature = getGpuSignature;
+module.exports.parseWhisperGpuBackend = parseWhisperGpuBackend;
module.exports.resolveWhisperThreads = resolveWhisperThreads;
module.exports.shouldFallbackToCpuAfterRequestError = shouldFallbackToCpuAfterRequestError;
module.exports.shouldRetryAfterServerReplaced = shouldRetryAfterServerReplaced;
diff --git a/src/helpers/whisperVulkanManager.js b/src/helpers/whisperVulkanManager.js
index 300040c1f8..4ff37f929b 100644
--- a/src/helpers/whisperVulkanManager.js
+++ b/src/helpers/whisperVulkanManager.js
@@ -4,6 +4,13 @@ const { WHISPER_CPP_TAG, WINDOWS_MSVC_RUNTIME_LIBRARIES } = require("./whisperCp
// Pinned so untested future binaries never auto-ship; bump together with the digests below
// sha256 per release tag; tags without an entry fall back to the API-reported digest
const EXPECTED_DIGESTS = {
+ // Same source as 0.0.10, rebuilt alongside the bundled Linux CUDA runtime
+ "0.0.11": {
+ "whisper-server-win32-x64-vulkan.zip":
+ "24790f91e3ffb161f4c2c9d1f3f6e9d138a23898808d13741b6cdd8921258029",
+ "whisper-server-linux-x64-vulkan.zip":
+ "aa14ad4196ceedbc958366ea5957b1f0497c9999eeef78a4159baa04236af497",
+ },
// Adds app-local MSVC runtime DLLs to the Windows archive
"0.0.10": {
"whisper-server-win32-x64-vulkan.zip":
diff --git a/src/hooks/useGpuBannerAvailability.ts b/src/hooks/useGpuBannerAvailability.ts
index 4d0a11e154..e64510e5ab 100644
--- a/src/hooks/useGpuBannerAvailability.ts
+++ b/src/hooks/useGpuBannerAvailability.ts
@@ -40,10 +40,12 @@ export function useGpuBannerAvailability({
try {
const status = await window.electronAPI?.getCudaWhisperStatus?.();
if (status?.gpuInfo.hasNvidiaGpu && status.gpuInfo.cudaSupported) {
- if (!status.downloaded) results.transcription = true;
+ if (!status.downloaded && !status.bundledGpu) results.transcription = true;
} else {
const vulkan = await window.electronAPI?.getVulkanWhisperStatus?.();
- if (vulkan?.vulkan.available && !vulkan.downloaded) results.transcription = true;
+ if (vulkan?.vulkan.available && !vulkan.downloaded && !vulkan.bundledGpu) {
+ results.transcription = true;
+ }
}
} catch {}
}
@@ -59,8 +61,12 @@ export function useGpuBannerAvailability({
if (!cancelled) setAvailability(results);
};
detect();
+ // A whisper-server that comes up on a bundled GPU build after this probe
+ // (the launch pre-warm) takes the offer back
+ const unsubscribe = window.electronAPI?.onWhisperGpuStatusChanged?.(detect);
return () => {
cancelled = true;
+ unsubscribe?.();
};
}, [settings, agentAllowedByPolicy, dismissed, settingsOpen, platform]);
diff --git a/src/locales/ar/translation.json b/src/locales/ar/translation.json
index 35f0d25a96..778c42d420 100644
--- a/src/locales/ar/translation.json
+++ b/src/locales/ar/translation.json
@@ -4826,6 +4826,12 @@
"active": "تسريع GPU نشط",
"ready": "تسريع GPU جاهز",
"activating": "جاري تفعيل GPU...",
+ "activeBackend": "تسريع {{backend}} نشط",
+ "readyBackend": "تسريع {{backend}} جاهز",
+ "activatingBackend": "جاري تفعيل {{backend}}...",
+ "enableBackend": "تفعيل {{backend}}",
+ "cudaDescription": "تسريع CUDA لوحدات معالجة الرسومات NVIDIA المدعومة. يتطلب تعريف NVIDIA حديثًا ومتوافقًا.",
+ "vulkanDescription": "تسريع Vulkan لوحدات معالجة الرسومات المتوافقة من AMD وIntel وNVIDIA.",
"activationFailed": "تسريع GPU غير متاح",
"activationFailedDescription": "انتقل OpenWhispr إلى استخدام وحدة المعالجة المركزية (CPU) للتفريغ الصوتي.",
"reasoningActivationFailedDescription": "انتقل OpenWhispr إلى استخدام وحدة المعالجة المركزية (CPU) للمعالجة.",
diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json
index e0895dbf0c..aa2f606597 100644
--- a/src/locales/de/translation.json
+++ b/src/locales/de/translation.json
@@ -3907,6 +3907,12 @@
"active": "GPU-Beschleunigung aktiv",
"ready": "GPU-Beschleunigung bereit",
"activating": "GPU wird aktiviert...",
+ "activeBackend": "{{backend}}-Beschleunigung aktiv",
+ "readyBackend": "{{backend}}-Beschleunigung bereit",
+ "activatingBackend": "{{backend}} wird aktiviert...",
+ "enableBackend": "{{backend}} aktivieren",
+ "cudaDescription": "CUDA-Beschleunigung für unterstützte NVIDIA-GPUs. Erfordert einen aktuellen kompatiblen NVIDIA-Treiber.",
+ "vulkanDescription": "Vulkan-Beschleunigung für kompatible AMD-, Intel- und NVIDIA-GPUs.",
"activationFailed": "GPU-Beschleunigung nicht verfügbar",
"activationFailedDescription": "OpenWhispr verwendet die CPU-Transkription.",
"reasoningActivationFailedDescription": "OpenWhispr verwendet die CPU-Verarbeitung.",
diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json
index 9c3b560dde..e84f428d5f 100644
--- a/src/locales/en/translation.json
+++ b/src/locales/en/translation.json
@@ -3907,6 +3907,12 @@
"active": "GPU acceleration active",
"ready": "GPU acceleration ready",
"activating": "Activating GPU...",
+ "activeBackend": "{{backend}} acceleration active",
+ "readyBackend": "{{backend}} acceleration ready",
+ "activatingBackend": "Activating {{backend}}...",
+ "enableBackend": "Enable {{backend}}",
+ "cudaDescription": "CUDA acceleration for supported NVIDIA GPUs. Requires a current compatible NVIDIA driver.",
+ "vulkanDescription": "Vulkan acceleration for compatible AMD, Intel, and NVIDIA GPUs.",
"activationFailed": "GPU acceleration unavailable",
"activationFailedDescription": "OpenWhispr switched to CPU transcription.",
"reasoningActivationFailedDescription": "OpenWhispr switched to CPU processing.",
diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json
index 9851f57ffb..6e323b55ed 100644
--- a/src/locales/es/translation.json
+++ b/src/locales/es/translation.json
@@ -3789,6 +3789,12 @@
"active": "Aceleración GPU activa",
"ready": "Aceleración GPU lista",
"activating": "Activando GPU...",
+ "activeBackend": "Aceleración {{backend}} activa",
+ "readyBackend": "Aceleración {{backend}} lista",
+ "activatingBackend": "Activando {{backend}}...",
+ "enableBackend": "Activar {{backend}}",
+ "cudaDescription": "Aceleración CUDA para GPU NVIDIA compatibles. Requiere un controlador NVIDIA actual y compatible.",
+ "vulkanDescription": "Aceleración Vulkan para GPU AMD, Intel y NVIDIA compatibles.",
"activationFailed": "Aceleración GPU no disponible",
"activationFailedDescription": "OpenWhispr cambió a la transcripción por CPU.",
"reasoningActivationFailedDescription": "OpenWhispr cambió al procesamiento por CPU.",
diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json
index c64f1ff862..7be0e3ad99 100644
--- a/src/locales/fr/translation.json
+++ b/src/locales/fr/translation.json
@@ -3907,6 +3907,12 @@
"active": "Accélération GPU active",
"ready": "Accélération GPU prête",
"activating": "Activation du GPU...",
+ "activeBackend": "Accélération {{backend}} active",
+ "readyBackend": "Accélération {{backend}} prête",
+ "activatingBackend": "Activation de {{backend}}...",
+ "enableBackend": "Activer {{backend}}",
+ "cudaDescription": "Accélération CUDA pour les GPU NVIDIA compatibles. Nécessite un pilote NVIDIA actuel et compatible.",
+ "vulkanDescription": "Accélération Vulkan pour les GPU AMD, Intel et NVIDIA compatibles.",
"activationFailed": "Accélération GPU indisponible",
"activationFailedDescription": "OpenWhispr est passé à la transcription par CPU.",
"reasoningActivationFailedDescription": "OpenWhispr est passé au traitement par CPU.",
diff --git a/src/locales/it/translation.json b/src/locales/it/translation.json
index e4e77bf8f0..0ecce34d7b 100644
--- a/src/locales/it/translation.json
+++ b/src/locales/it/translation.json
@@ -3737,6 +3737,12 @@
"active": "Accelerazione GPU attiva",
"ready": "Accelerazione GPU pronta",
"activating": "Attivazione GPU...",
+ "activeBackend": "Accelerazione {{backend}} attiva",
+ "readyBackend": "Accelerazione {{backend}} pronta",
+ "activatingBackend": "Attivazione di {{backend}}...",
+ "enableBackend": "Attiva {{backend}}",
+ "cudaDescription": "Accelerazione CUDA per GPU NVIDIA supportate. Richiede un driver NVIDIA aggiornato e compatibile.",
+ "vulkanDescription": "Accelerazione Vulkan per GPU AMD, Intel e NVIDIA compatibili.",
"activationFailed": "Accelerazione GPU non disponibile",
"activationFailedDescription": "OpenWhispr sta usando la trascrizione CPU.",
"reasoningActivationFailedDescription": "OpenWhispr sta usando l'elaborazione CPU.",
diff --git a/src/locales/ja/translation.json b/src/locales/ja/translation.json
index 0222143a99..ad6218edf7 100644
--- a/src/locales/ja/translation.json
+++ b/src/locales/ja/translation.json
@@ -3737,6 +3737,12 @@
"active": "GPU アクセラレーション有効",
"ready": "GPUアクセラレーション準備完了",
"activating": "GPU を有効化中...",
+ "activeBackend": "{{backend}} アクセラレーション有効",
+ "readyBackend": "{{backend}} アクセラレーション準備完了",
+ "activatingBackend": "{{backend}} を有効化中...",
+ "enableBackend": "{{backend}} を有効化",
+ "cudaDescription": "対応する NVIDIA GPU 向けの CUDA アクセラレーションです。現在の互換性のある NVIDIA ドライバーが必要です。",
+ "vulkanDescription": "対応する AMD、Intel、NVIDIA GPU 向けの Vulkan アクセラレーションです。",
"activationFailed": "GPU アクセラレーションを利用できません",
"activationFailedDescription": "OpenWhispr は CPU 文字起こしに切り替えました。",
"reasoningActivationFailedDescription": "OpenWhispr は CPU 処理に切り替えました。",
diff --git a/src/locales/pt/translation.json b/src/locales/pt/translation.json
index a13827f017..4525c70d7c 100644
--- a/src/locales/pt/translation.json
+++ b/src/locales/pt/translation.json
@@ -3737,6 +3737,12 @@
"active": "Aceleração GPU ativa",
"ready": "Aceleração GPU pronta",
"activating": "Ativando GPU...",
+ "activeBackend": "Aceleração {{backend}} ativa",
+ "readyBackend": "Aceleração {{backend}} pronta",
+ "activatingBackend": "Ativando {{backend}}...",
+ "enableBackend": "Ativar {{backend}}",
+ "cudaDescription": "Aceleração CUDA para GPUs NVIDIA compatíveis. Requer um driver NVIDIA atual e compatível.",
+ "vulkanDescription": "Aceleração Vulkan para GPUs AMD, Intel e NVIDIA compatíveis.",
"activationFailed": "Aceleração GPU indisponível",
"activationFailedDescription": "OpenWhispr mudou para a transcrição por CPU.",
"reasoningActivationFailedDescription": "OpenWhispr mudou para o processamento por CPU.",
diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json
index c757dd305e..84495ce364 100644
--- a/src/locales/ru/translation.json
+++ b/src/locales/ru/translation.json
@@ -3781,6 +3781,12 @@
"active": "Ускорение GPU активно",
"ready": "Ускорение GPU готово",
"activating": "Активация GPU...",
+ "activeBackend": "Ускорение {{backend}} активно",
+ "readyBackend": "Ускорение {{backend}} готово",
+ "activatingBackend": "Активация {{backend}}...",
+ "enableBackend": "Включить {{backend}}",
+ "cudaDescription": "Ускорение CUDA для поддерживаемых GPU NVIDIA. Требуется актуальный совместимый драйвер NVIDIA.",
+ "vulkanDescription": "Ускорение Vulkan для совместимых GPU AMD, Intel и NVIDIA.",
"activationFailed": "Ускорение GPU недоступно",
"activationFailedDescription": "OpenWhispr переключился на транскрипцию на CPU.",
"reasoningActivationFailedDescription": "OpenWhispr переключился на обработку на CPU.",
diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json
index a9e94f511f..53819a8708 100644
--- a/src/locales/zh-CN/translation.json
+++ b/src/locales/zh-CN/translation.json
@@ -3737,6 +3737,12 @@
"active": "GPU 加速已启用",
"ready": "GPU 加速已就绪",
"activating": "正在激活 GPU...",
+ "activeBackend": "{{backend}} 加速已启用",
+ "readyBackend": "{{backend}} 加速已就绪",
+ "activatingBackend": "正在启用 {{backend}}...",
+ "enableBackend": "启用 {{backend}}",
+ "cudaDescription": "适用于受支持的 NVIDIA GPU 的 CUDA 加速。需要当前兼容的 NVIDIA 驱动程序。",
+ "vulkanDescription": "适用于兼容的 AMD、Intel 和 NVIDIA GPU 的 Vulkan 加速。",
"activationFailed": "GPU 加速不可用",
"activationFailedDescription": "OpenWhispr 已切换为 CPU 转录。",
"reasoningActivationFailedDescription": "OpenWhispr 已切换为 CPU 处理。",
diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json
index f754acbf18..c7d0a2bdc5 100644
--- a/src/locales/zh-TW/translation.json
+++ b/src/locales/zh-TW/translation.json
@@ -3737,6 +3737,12 @@
"active": "GPU 加速已啟用",
"ready": "GPU 加速已就緒",
"activating": "正在啟動 GPU...",
+ "activeBackend": "{{backend}} 加速已啟用",
+ "readyBackend": "{{backend}} 加速已就緒",
+ "activatingBackend": "正在啟用 {{backend}}...",
+ "enableBackend": "啟用 {{backend}}",
+ "cudaDescription": "適用於支援的 NVIDIA GPU 的 CUDA 加速。需要目前相容的 NVIDIA 驅動程式。",
+ "vulkanDescription": "適用於相容的 AMD、Intel 和 NVIDIA GPU 的 Vulkan 加速。",
"activationFailed": "GPU 加速無法使用",
"activationFailedDescription": "OpenWhispr 已切換為 CPU 轉錄。",
"reasoningActivationFailedDescription": "OpenWhispr 已切換為 CPU 處理。",
diff --git a/src/types/electron.ts b/src/types/electron.ts
index 277611f55b..2cc03fdcb8 100644
--- a/src/types/electron.ts
+++ b/src/types/electron.ts
@@ -901,6 +901,8 @@ export interface CudaWhisperStatus {
inUse?: boolean;
/** An older release installed the pack and this version can't use it. */
needsUpdate?: boolean;
+ /** The bundled whisper-server is itself a GPU build and runs on the GPU, so no pack is offered. */
+ bundledGpu?: boolean;
}
export interface VulkanWhisperStatus {
@@ -917,6 +919,8 @@ export interface VulkanWhisperStatus {
inUse?: boolean;
/** An older release installed the pack and this version can't use it. */
needsUpdate?: boolean;
+ /** The bundled whisper-server is itself a GPU build and runs on the GPU, so no pack is offered. */
+ bundledGpu?: boolean;
}
export interface WhisperServerStatus {
diff --git a/src/utils/gpuDetection.js b/src/utils/gpuDetection.js
index ff1eb10cdf..23c03e1740 100644
--- a/src/utils/gpuDetection.js
+++ b/src/utils/gpuDetection.js
@@ -3,7 +3,7 @@ const fs = require("fs");
const path = require("path");
const debugLogger = require("../helpers/debugLogger");
-// The shipped CUDA whisper build (release 0.0.10) carries kernels for Pascal
+// The shipped CUDA whisper build (release 0.0.9 and later) carries kernels for Pascal
// (compute capability 6.1) and newer; 6.1's embedded PTX also covers Volta via
// driver JIT. On cards below the floor the server starts, loads the model into
// VRAM, then aborts on the first kernel launch with "no kernel image is
diff --git a/src/utils/whisperGpuPack.ts b/src/utils/whisperGpuPack.ts
index 7655c5272d..877cd5928d 100644
--- a/src/utils/whisperGpuPack.ts
+++ b/src/utils/whisperGpuPack.ts
@@ -10,9 +10,10 @@ export type WhisperGpuBackend = "cuda" | "vulkan";
// comes a pack an older release installed that this version can't use (#2424):
// the startup notice names it, so the card offers its re-download even when GPU
// detection no longer sees the GPU (RDP, a VM, a failed nvidia-smi). Last, a
-// first-time offer. Cards below the CUDA build's kernel floor (e.g. Maxwell)
-// crash at the first kernel launch, so they are never offered the CUDA pack and
-// get the Vulkan pack like AMD/Intel GPUs.
+// first-time offer, unless the bundled whisper-server already runs on the GPU
+// (a distro GPU build, #2168). Cards below the CUDA build's kernel floor (e.g.
+// Maxwell) crash at the first kernel launch, so they are never offered the CUDA
+// pack and get the Vulkan pack like AMD/Intel GPUs.
export function pickWhisperGpuBackend(
cuda: CudaWhisperStatus | null | undefined,
vulkan: VulkanWhisperStatus | null | undefined
@@ -26,6 +27,7 @@ export function pickWhisperGpuBackend(
if (vulkanAvailable && vulkan?.downloaded) return "vulkan";
if (cuda?.needsUpdate && !cudaBelowFloor) return "cuda";
if (vulkan?.needsUpdate) return "vulkan";
+ if (cuda?.bundledGpu || vulkan?.bundledGpu) return null;
if (cudaEligible) return "cuda";
if (vulkanAvailable) return "vulkan";
return null;
diff --git a/test/components/gpuFailureReasonCard.test.js b/test/components/gpuFailureReasonCard.test.js
index 6946c98d5d..aa9a392765 100644
--- a/test/components/gpuFailureReasonCard.test.js
+++ b/test/components/gpuFailureReasonCard.test.js
@@ -82,7 +82,7 @@ function settle() {
});
}
-async function mountPicker(t, vulkanStatus, cudaStatus = cudaPack(), gpuAccelerated = false) {
+async function mountPicker(t, vulkanStatus, cudaStatus = cudaPack(), gpuBackend = null) {
installBrowserGlobals(t, {
window: { location: { search: "" }, electronAPI: { getPlatform: () => "win32" } },
});
@@ -95,7 +95,7 @@ async function mountPicker(t, vulkanStatus, cudaStatus = cudaPack(), gpuAccelera
const pack = {
status: vulkanStatus,
cuda: cudaStatus,
- gpuAccelerated,
+ gpuBackend,
failReads: false,
};
const calls = [];
@@ -120,7 +120,10 @@ async function mountPicker(t, vulkanStatus, cudaStatus = cudaPack(), gpuAccelera
onParakeetDownloadProgress: () => noop,
getCudaWhisperStatus: read(() => pack.cuda),
getVulkanWhisperStatus: read(() => pack.status),
- whisperServerStatus: async () => ({ gpuAccelerated: pack.gpuAccelerated }),
+ whisperServerStatus: async () => ({
+ gpuAccelerated: pack.gpuBackend !== null,
+ gpuBackend: pack.gpuBackend,
+ }),
onCudaFallbackNotification: listen(listeners.cuda),
onGpuFallbackNotification: listen(listeners.vulkan),
onWhisperGpuStatusChanged: listen(listeners.changed),
@@ -154,7 +157,7 @@ async function mountPicker(t, vulkanStatus, cudaStatus = cudaPack(), gpuAccelera
});
await settle();
// Main has already saved the change when it sends any of these events.
- // `next` replaces what the status IPCs answer: { status, cuda, gpuAccelerated }.
+ // `next` replaces what the status IPCs answer: { status, cuda, gpuBackend }.
const fire = async (event, next = {}) => {
Object.assign(pack, next);
await React.act(async () => {
@@ -259,7 +262,8 @@ for (const [name, packs, reason, removes] of [
const picker = await mountPicker(t, packs.vulkan, packs.cuda);
try {
assert.equal(picker.shows(isFailedCard), !!reason);
- assert.ok(picker.shows(hasText(reason ?? "GPU acceleration ready")));
+ const ready = `${removes === "delete-cuda" ? "CUDA" : "Vulkan"} acceleration ready`;
+ assert.ok(picker.shows(hasText(reason ?? ready)));
assert.deepEqual(await picker.click("Remove"), [removes]);
} finally {
await picker.unmount();
@@ -267,14 +271,14 @@ for (const [name, packs, reason, removes] of [
});
}
-for (const [name, gpuInfo, downloads] of [
- ["an NVIDIA GPU", NVIDIA, "download-cuda"],
- ["another GPU", { hasNvidiaGpu: false }, "download-vulkan"],
+for (const [name, gpuInfo, label, downloads] of [
+ ["an NVIDIA GPU", NVIDIA, "Enable CUDA", "download-cuda"],
+ ["another GPU", { hasNvidiaGpu: false }, "Enable Vulkan", "download-vulkan"],
]) {
test(`no pack on ${name}: the card offers the pack that GPU can run`, async (t) => {
const picker = await mountPicker(t, vulkanPack({ downloaded: false }), cudaPack({ gpuInfo }));
try {
- assert.deepEqual(await picker.click("Enable GPU"), [downloads]);
+ assert.deepEqual(await picker.click(label), [downloads]);
} finally {
await picker.unmount();
}
@@ -313,7 +317,8 @@ for (const [name, packs, reason, removes] of [
const picker = await mountPicker(t, packs.vulkan, packs.cuda);
try {
assert.equal(picker.shows(isFailedCard), !!reason);
- assert.ok(picker.shows(hasText(reason ?? "GPU acceleration ready")));
+ const ready = `${removes === "delete-cuda" ? "CUDA" : "Vulkan"} acceleration ready`;
+ assert.ok(picker.shows(hasText(reason ?? ready)));
for (const other of [KERNEL_IMAGE, DEVICE_LOST].filter((line) => line !== reason)) {
assert.equal(picker.shows(hasText(other)), false, "no other pack's reason");
}
@@ -352,7 +357,24 @@ test("both packs: after a CUDA fallback the card follows main to the Vulkan pack
await picker.fire("cuda", { status: after.vulkan, cuda: after.cuda });
assert.equal(picker.shows(isFailedCard), false);
- assert.ok(picker.shows(hasText("GPU acceleration ready")));
+ assert.ok(picker.shows(hasText("Vulkan acceleration ready")));
+ assert.deepEqual(await picker.click("Remove"), ["delete-vulkan"]);
+ } finally {
+ await picker.unmount();
+ }
+});
+
+test("a failed pack keeps its card while a bundled GPU build of whisper-server runs (#2168)", async (t) => {
+ // The Vulkan pack failed, and the bundled server it fell back to is a distro CUDA build
+ const picker = await mountPicker(
+ t,
+ vulkanPack({ inUse: true, ...failedWith(DEVICE_LOST) }),
+ cudaPack({ gpuInfo: NVIDIA }),
+ "cuda"
+ );
+ try {
+ assert.ok(picker.shows(isFailedCard));
+ assert.ok(picker.shows(hasText(DEVICE_LOST)));
assert.deepEqual(await picker.click("Remove"), ["delete-vulkan"]);
} finally {
await picker.unmount();
@@ -385,7 +407,7 @@ test("the card re-reads when main says the GPU state changed, without remounting
assert.equal(picker.shows(isFailedCard), false);
assert.equal(picker.shows(hasText(DEVICE_LOST)), false, "the old reason is gone");
- assert.ok(picker.shows(hasText("GPU acceleration ready")));
+ assert.ok(picker.shows(hasText("Vulkan acceleration ready")));
} finally {
await picker.unmount();
}
@@ -393,19 +415,19 @@ test("the card re-reads when main says the GPU state changed, without remounting
test("a card that switches packs reads the server again, not the old pack's state", async (t) => {
const vulkanRunning = bothPacks({ inUse: "vulkan" });
- const picker = await mountPicker(t, vulkanRunning.vulkan, vulkanRunning.cuda, true);
+ const picker = await mountPicker(t, vulkanRunning.vulkan, vulkanRunning.cuda, "vulkan");
try {
- assert.ok(picker.shows(hasText("GPU acceleration active")));
+ assert.ok(picker.shows(hasText("Vulkan acceleration active")));
// CUDA is opted out: Remove on another tab deleted Vulkan, so the server restarts on CPU
const cudaLeft = onlyCuda({ inUse: false });
await picker.fire("changed", {
status: cudaLeft.vulkan,
cuda: cudaLeft.cuda,
- gpuAccelerated: false,
+ gpuBackend: null,
});
- assert.ok(picker.shows(hasText("GPU acceleration ready")), "without waiting for the 5 s poll");
+ assert.ok(picker.shows(hasText("CUDA acceleration ready")), "without waiting for the 5 s poll");
} finally {
await picker.unmount();
}
diff --git a/test/helpers/gpuBinaryManager.test.js b/test/helpers/gpuBinaryManager.test.js
index 9c4479487f..77979821c1 100644
--- a/test/helpers/gpuBinaryManager.test.js
+++ b/test/helpers/gpuBinaryManager.test.js
@@ -157,14 +157,16 @@ test("CUDA: resolves its exact asset from the pinned tag and installs binary + c
state.release = makeRelease("whisper-server-linux-x64-cuda.zip");
state.extractedFiles = {
"whisper-server-linux-x64-cuda": "binary",
- "libggml-cuda.so": "lib",
+ "libcudart.so.12": "cudart",
+ "libcublas.so.12": "cublas",
+ "libcublasLt.so.12": "cublasLt",
"README.md": "doc",
};
const manager = cudaManagerWithoutDigestPin();
await manager.download();
- assert.match(state.fetchedUrls[0], /OpenWhispr\/whisper\.cpp\/releases\/tags\/0\.0\.10$/);
+ assert.match(state.fetchedUrls[0], /OpenWhispr\/whisper\.cpp\/releases\/tags\/0\.0\.11$/);
assert.equal(state.downloads[0].url, "https://dl/whisper-server-linux-x64-cuda.zip");
const binDir = path.join(userDataDir, "bin", "whisper-cuda");
@@ -173,7 +175,9 @@ test("CUDA: resolves its exact asset from the pinned tag and installs binary + c
if (process.env.OS !== "Windows_NT") {
assert.ok(fs.statSync(binaryPath).mode & 0o100, "binary is executable");
}
- assert.ok(fs.existsSync(path.join(binDir, "libggml-cuda.so")), "companion lib copied");
+ assert.ok(fs.existsSync(path.join(binDir, "libcudart.so.12")));
+ assert.ok(fs.existsSync(path.join(binDir, "libcublas.so.12")));
+ assert.ok(fs.existsSync(path.join(binDir, "libcublasLt.so.12")));
assert.ok(!fs.existsSync(path.join(binDir, "README.md")), "unrelated files not copied");
assert.equal(manager.getCudaBinaryPath(), binaryPath);
assert.equal(manager.isDownloaded(), true);
@@ -316,7 +320,12 @@ test("guard: a second download while one is in flight throws", async () => {
await gate;
fs.writeFileSync(dest, state.archiveContent);
};
- state.extractedFiles = { "whisper-server-linux-x64-cuda": "binary" };
+ state.extractedFiles = {
+ "whisper-server-linux-x64-cuda": "binary",
+ "libcudart.so.12": "cudart",
+ "libcublas.so.12": "cublas",
+ "libcublasLt.so.12": "cublasLt",
+ };
const manager = cudaManagerWithoutDigestPin();
const first = manager.download();
@@ -339,6 +348,9 @@ test("cleanup on failure: archive and extract dir removed, next download can sta
state.extractImpl = async (_archivePath, destDir) => {
fs.writeFileSync(path.join(destDir, "whisper-server-linux-x64-cuda"), "binary");
+ fs.writeFileSync(path.join(destDir, "libcudart.so.12"), "cudart");
+ fs.writeFileSync(path.join(destDir, "libcublas.so.12"), "cublas");
+ fs.writeFileSync(path.join(destDir, "libcublasLt.so.12"), "cublasLt");
};
await manager.download();
assert.equal(manager.isDownloaded(), true);
@@ -381,25 +393,33 @@ test("delete: removes only the pack's own directory; other packs untouched", asy
test("install isolation: packs sharing lib names cannot clobber each other", async () => {
state.release = makeRelease("whisper-server-linux-x64-cuda.zip");
- state.extractedFiles = { "whisper-server-linux-x64-cuda": "bin", "libggml-base.so": "cuda-ggml" };
+ state.extractedFiles = {
+ "whisper-server-linux-x64-cuda": "bin",
+ "libcudart.so.12": "cuda-cudart",
+ "libcublas.so.12": "cublas",
+ "libcublasLt.so.12": "cublasLt",
+ };
await cudaManagerWithoutDigestPin().download();
state.release = makeRelease("llama-b9763-bin-ubuntu-vulkan-x64.tar.gz");
- state.extractedFiles = { "llama-server": "bin", "libggml-base.so": "llama-ggml" };
+ state.extractedFiles = { "llama-server": "bin", "libcudart.so.12": "llama-cudart" };
await new LlamaVulkanManager().download();
const read = (dir) =>
- fs.readFileSync(path.join(userDataDir, "bin", dir, "libggml-base.so"), "utf8");
- assert.equal(read("whisper-cuda"), "cuda-ggml");
- assert.equal(read("llama-vulkan"), "llama-ggml");
+ fs.readFileSync(path.join(userDataDir, "bin", dir, "libcudart.so.12"), "utf8");
+ assert.equal(read("whisper-cuda"), "cuda-cudart");
+ assert.equal(read("llama-vulkan"), "llama-cudart");
});
-test("atomic install: a failure after extraction leaves no half-installed pack", async () => {
+test("atomic install: an incomplete CUDA runtime leaves no half-installed pack", async () => {
state.release = makeRelease("whisper-server-linux-x64-cuda.zip");
- state.extractedFiles = { "not-the-binary": "x" };
+ state.extractedFiles = { "whisper-server-linux-x64-cuda": "binary" };
const manager = cudaManagerWithoutDigestPin();
- await assert.rejects(() => manager.download(), { message: /not found in archive/ });
+ await assert.rejects(() => manager.download(), {
+ message:
+ /missing required libraries: libcudart\.so\.12, libcublas\.so\.12, libcublasLt\.so\.12/,
+ });
assert.equal(manager.isDownloaded(), false);
assert.ok(!fs.existsSync(path.join(userDataDir, "bin", "whisper-cuda")));
@@ -427,11 +447,7 @@ test("required libraries: a cached pack is incomplete when a required library is
});
test("required libraries: an incomplete archive cannot replace a working pack", async () => {
- const packDir = seedPack("test-pack", [
- "server-out",
- "msvcp140.dll",
- "vcruntime140.dll",
- ]);
+ const packDir = seedPack("test-pack", ["server-out", "msvcp140.dll", "vcruntime140.dll"]);
fs.writeFileSync(path.join(packDir, "server-out"), "working-binary");
state.release = makeRelease("bin.zip");
@@ -497,9 +513,22 @@ test("needs update: a 1.9.x Windows CUDA pack (whisper.cpp 0.0.9, no MSVC runtim
assert.equal(manager.needsUpdate(), true);
});
-test("needs update: a pack with no required libraries is never outdated", () => {
- seedPack("whisper-cuda", ["whisper-server-linux-x64-cuda"]);
+test("needs update: a whisper.cpp 0.0.10 Linux CUDA pack, which relied on a system CUDA 12, is outdated (#2168)", () => {
const manager = new WhisperCudaManager();
+ const packDir = seedPack(manager.config.dirName, ["whisper-server-linux-x64-cuda"]);
+ assert.equal(manager.isDownloaded(), false);
+ assert.equal(manager.needsUpdate(), true);
+
+ for (const library of ["libcudart.so.12", "libcublas.so.12", "libcublasLt.so.12"]) {
+ fs.writeFileSync(path.join(packDir, library), "runtime");
+ }
+ assert.equal(manager.isDownloaded(), true);
+ assert.equal(manager.needsUpdate(), false);
+});
+
+test("needs update: a pack with no required libraries is never outdated", () => {
+ seedPack("whisper-vulkan", ["whisper-server-linux-x64-vulkan"]);
+ const manager = new WhisperVulkanManager();
assert.equal(manager.isDownloaded(), true);
assert.equal(manager.needsUpdate(), false);
});
@@ -530,12 +559,19 @@ test("re-download replaces the previous install, including stale libs", async ()
seedPack("whisper-cuda", ["whisper-server-linux-x64-cuda", "libstale.so"]);
state.release = makeRelease("whisper-server-linux-x64-cuda.zip");
- state.extractedFiles = { "whisper-server-linux-x64-cuda": "new", "libggml-cuda.so": "lib" };
+ state.extractedFiles = {
+ "whisper-server-linux-x64-cuda": "new",
+ "libcudart.so.12": "cudart",
+ "libcublas.so.12": "cublas",
+ "libcublasLt.so.12": "cublasLt",
+ };
await cudaManagerWithoutDigestPin().download();
const packDir = path.join(userDataDir, "bin", "whisper-cuda");
assert.deepEqual(fs.readdirSync(packDir).sort(), [
- "libggml-cuda.so",
+ "libcublas.so.12",
+ "libcublasLt.so.12",
+ "libcudart.so.12",
"whisper-server-linux-x64-cuda",
]);
});
@@ -607,7 +643,12 @@ test("orphan detection: enabled flag with no pack on disk is reported for the no
assert.deepEqual(detectOrphanedGpuPacks(packs), ["CUDA whisper"]);
// Pack present on disk — enabled but not orphaned
- seedPack("whisper-cuda", ["whisper-server-linux-x64-cuda"]);
+ seedPack("whisper-cuda", [
+ "whisper-server-linux-x64-cuda",
+ "libcudart.so.12",
+ "libcublas.so.12",
+ "libcublasLt.so.12",
+ ]);
assert.deepEqual(detectOrphanedGpuPacks(packs), []);
process.env.WHISPER_VULKAN_ENABLED = "true";
diff --git a/test/helpers/whisperCudaRequestFallback.test.js b/test/helpers/whisperCudaRequestFallback.test.js
index 4f83c4e27d..c17bd898ae 100644
--- a/test/helpers/whisperCudaRequestFallback.test.js
+++ b/test/helpers/whisperCudaRequestFallback.test.js
@@ -98,7 +98,7 @@ function createDeferred() {
return { promise, resolve, reject };
}
-function createManager(port, { useCuda, useVulkan = false }) {
+function createManager(port, { useCuda, useVulkan = false, fallbackToVulkan = false }) {
const manager = new WhisperServerManager();
manager.ready = true;
manager.hostname = "127.0.0.1";
@@ -109,7 +109,7 @@ function createManager(port, { useCuda, useVulkan = false }) {
manager.process = {};
manager._lastProcessInfo = () => ({ stderr: "", exitCode: null, signal: null });
manager.modelPath = "/tmp/model.bin";
- manager.lastStartOptions = { useCuda, useVulkan };
+ manager.lastStartOptions = { useCuda, useVulkan, fallbackToVulkan };
manager._convertToWav = async (buffer) => buffer;
return manager;
}
@@ -156,6 +156,49 @@ test("falls back to CPU and retries once when the CUDA server dies mid-request",
assert.equal(startCalls[0].options.useVulkan, false);
});
+test("falls back to an available Vulkan pack when CUDA dies mid-request", async (t) => {
+ let manager;
+ let requestCount = 0;
+
+ const { server, port } = await startServer((req, res) => {
+ requestCount += 1;
+ if (requestCount === 1) {
+ manager.process = null;
+ req.socket.destroy();
+ return;
+ }
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ text: "hello" }));
+ });
+ t.after(() => server.close());
+
+ manager = createManager(port, { useCuda: true, fallbackToVulkan: true });
+
+ const startCalls = [];
+ manager.start = async (modelPath, options) => {
+ startCalls.push({ modelPath, options });
+ manager.useCuda = false;
+ manager.useVulkan = true;
+ manager.ready = true;
+ };
+
+ let fallbackBackend = null;
+ manager.on("cuda-fallback", (details) => {
+ fallbackBackend = details.fallbackBackend;
+ });
+
+ const result = await manager.transcribe(Buffer.from("audio"));
+
+ assert.equal(result.text, "hello");
+ assert.equal(requestCount, 2);
+ assert.equal(fallbackBackend, "vulkan");
+ assert.equal(startCalls.length, 1);
+ assert.equal(startCalls[0].options.useCuda, false);
+ assert.equal(startCalls[0].options.useVulkan, true);
+ assert.equal(Object.hasOwn(startCalls[0].options, "fallbackToVulkan"), false);
+ assert.equal(manager.gpuFallbackActive, false);
+});
+
test("falls back to CPU and emits gpu-fallback when a Vulkan server dies mid-request", async (t) => {
let manager;
let requestCount = 0;
@@ -231,7 +274,10 @@ test("the mid-transcription fallback carries the crashed server's error line", a
assert.equal(result.text, "hello");
assert.deepEqual(events, [
- { reason: "CUDA error: no kernel image is available for execution on the device" },
+ {
+ reason: "CUDA error: no kernel image is available for execution on the device",
+ fallbackBackend: "cpu",
+ },
]);
});
@@ -268,7 +314,7 @@ test("the mid-transcription reason ignores what earlier requests printed", async
await manager.transcribe(Buffer.from("audio"));
- assert.deepEqual(events, [{ reason: "exit code 0xC0000005" }]);
+ assert.deepEqual(events, [{ reason: "exit code 0xC0000005", fallbackBackend: "cpu" }]);
});
test("falls back to CPU when a peer's replacement is another doomed CUDA server", async (t) => {
@@ -322,7 +368,10 @@ test("falls back to CPU when a peer's replacement is another doomed CUDA server"
assert.equal(result.text, "hello");
assert.equal(requestCount, 3);
assert.deepEqual(fallbackEvents, [
- { reason: "CUDA error: no kernel image is available for execution on the device" },
+ {
+ reason: "CUDA error: no kernel image is available for execution on the device",
+ fallbackBackend: "cpu",
+ },
]);
assert.equal(startCalls.length, 1);
assert.equal(startCalls[0].options.useCuda, false);
diff --git a/test/helpers/whisperGpuFailureRecordIpc.test.js b/test/helpers/whisperGpuFailureRecordIpc.test.js
index ef66bdc241..35d46b3646 100644
--- a/test/helpers/whisperGpuFailureRecordIpc.test.js
+++ b/test/helpers/whisperGpuFailureRecordIpc.test.js
@@ -175,7 +175,7 @@ function createHandlers({ downloadError = null } = {}) {
test("a Vulkan fallback saves its reason with the flag", () => {
const { serverManager, envWrites } = createHandlers();
- serverManager.emit("gpu-fallback", { reason: DEVICE_LOST });
+ serverManager.emit("gpu-fallback", { reason: DEVICE_LOST, fallbackBackend: "cpu" });
assert.equal(process.env.WHISPER_GPU_FAILED, "vulkan");
assert.equal(process.env.WHISPER_GPU_FAILED_REASON_VULKAN, DEVICE_LOST);
@@ -334,3 +334,56 @@ test("a pack download that fails changes nothing and announces nothing", async (
assert.equal((await invoke("download-vulkan-whisper-binary")).success, false);
assert.deepEqual(toldToReread(), []);
});
+
+test("a bundled whisper-server running on the GPU is reported and tells every window (#2168)", async () => {
+ const { serverManager, invoke } = createHandlers();
+ const bundled = async () => [
+ (await invoke("get-cuda-whisper-status")).bundledGpu,
+ (await invoke("get-vulkan-whisper-status")).bundledGpu,
+ ];
+ assert.deepEqual(await bundled(), [false, false], "no server running");
+
+ // A start with no pack flags whose log says it initialized CUDA
+ Object.assign(serverManager, { ready: true, process: {}, activeGpuBackend: "cuda" });
+ serverManager.emit("bundled-gpu");
+
+ assert.deepEqual(await bundled(), [true, true]);
+ assert.deepEqual(toldToReread(), [
+ ["dictation", undefined],
+ ["control-panel", undefined],
+ ]);
+
+ serverManager.useCuda = true;
+ assert.deepEqual(await bundled(), [false, false], "a pack's GPU is not the bundled server's");
+});
+
+test("a CUDA failure that lands on Vulkan is saved, with no CPU pop-up, and every window re-reads", () => {
+ const { serverManager, envWrites } = createHandlers();
+
+ serverManager.emit("cuda-fallback", { reason: KERNEL_IMAGE, fallbackBackend: "vulkan" });
+
+ assert.equal(process.env.WHISPER_GPU_FAILED, "cuda");
+ assert.equal(envWrites.at(-1).WHISPER_GPU_FAILED_REASON_CUDA, KERNEL_IMAGE);
+ assert.deepEqual(
+ broadcasts.map(({ window, channel, failed }) => [window, channel, failed]),
+ [
+ ["dictation", STATUS_CHANGED, "cuda"],
+ ["control-panel", STATUS_CHANGED, "cuda"],
+ ]
+ );
+});
+
+test("a Vulkan failure that lands on a bundled GPU build is saved, with no CPU pop-up (#2168)", () => {
+ const { serverManager } = createHandlers();
+
+ serverManager.emit("gpu-fallback", { reason: DEVICE_LOST, fallbackBackend: "cuda" });
+
+ assert.equal(process.env.WHISPER_GPU_FAILED, "vulkan");
+ assert.deepEqual(
+ broadcasts.map(({ window, channel }) => [window, channel]),
+ [
+ ["dictation", STATUS_CHANGED],
+ ["control-panel", STATUS_CHANGED],
+ ]
+ );
+});
diff --git a/test/helpers/whisperGpuResolve.test.js b/test/helpers/whisperGpuResolve.test.js
index 4b20815695..0aba33c167 100644
--- a/test/helpers/whisperGpuResolve.test.js
+++ b/test/helpers/whisperGpuResolve.test.js
@@ -1,5 +1,8 @@
const test = require("node:test");
const assert = require("node:assert/strict");
+const fs = require("fs");
+const os = require("os");
+const path = require("path");
const WhisperManager = require("../../src/helpers/whisper.js");
const { resolveFailedGpuBackends } = require("../../src/helpers/whisper.js");
@@ -42,26 +45,95 @@ function managerWith({
}
test("no packs enabled resolves to CPU", () => {
- assert.deepEqual(managerWith().resolveGpuStartOptions(), { useCuda: false, useVulkan: false });
+ assert.deepEqual(managerWith().resolveGpuStartOptions(), {
+ useCuda: false,
+ useVulkan: false,
+ fallbackToVulkan: false,
+ });
});
test("an enabled + downloaded pack engages without an app restart", () => {
process.env.WHISPER_CUDA_ENABLED = "true";
const manager = managerWith({ cudaDownloaded: true });
- assert.deepEqual(manager.resolveGpuStartOptions(), { useCuda: true, useVulkan: false });
+ assert.deepEqual(manager.resolveGpuStartOptions(), {
+ useCuda: true,
+ useVulkan: false,
+ fallbackToVulkan: false,
+ });
});
test("enabled but not downloaded resolves to CPU (env flag alone is not an install)", () => {
process.env.WHISPER_CUDA_ENABLED = "true";
process.env.WHISPER_VULKAN_ENABLED = "true";
- assert.deepEqual(managerWith().resolveGpuStartOptions(), { useCuda: false, useVulkan: false });
+ assert.deepEqual(managerWith().resolveGpuStartOptions(), {
+ useCuda: false,
+ useVulkan: false,
+ fallbackToVulkan: false,
+ });
});
test("CUDA wins when both backends are enabled and downloaded", () => {
process.env.WHISPER_CUDA_ENABLED = "true";
process.env.WHISPER_VULKAN_ENABLED = "true";
const manager = managerWith({ cudaDownloaded: true, vulkanDownloaded: true });
- assert.deepEqual(manager.resolveGpuStartOptions(), { useCuda: true, useVulkan: false });
+ assert.deepEqual(manager.resolveGpuStartOptions(), {
+ useCuda: true,
+ useVulkan: false,
+ fallbackToVulkan: true,
+ });
+});
+
+test("a restart after the Vulkan pack is removed drops the old Vulkan fallback", async () => {
+ process.env.WHISPER_CUDA_ENABLED = "true";
+ const manager = managerWith({ cudaDownloaded: true, vulkanDownloaded: false });
+ // The last start had both packs; restarts merge their options over it
+ manager.serverManager.lastStartOptions = {
+ useCuda: true,
+ useVulkan: false,
+ fallbackToVulkan: true,
+ };
+ let startOptions = null;
+ manager.stopServer = async () => {};
+ manager.startServer = async (_model, options) => {
+ startOptions = options;
+ return { success: true };
+ };
+
+ await manager.restartServerWithGpuPreference("base");
+
+ assert.equal(startOptions.fallbackToVulkan, false);
+});
+
+test("startup pre-warm preserves the installed Vulkan fallback", async () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "whisper-prewarm-"));
+ const modelPath = path.join(dir, "ggml-base.bin");
+ fs.writeFileSync(modelPath, "model");
+
+ try {
+ const manager = managerWith({ cudaDownloaded: true, vulkanDownloaded: true });
+ manager.getModelsDir = () => dir;
+ manager.getModelPath = () => modelPath;
+ manager.logDependencyStatus = async () => {};
+ manager.serverManager.isAvailable = () => true;
+
+ let startOptions = null;
+ manager.serverManager.start = async (_modelPath, options) => {
+ startOptions = options;
+ };
+
+ await manager.initializeAtStartup({
+ localTranscriptionProvider: "whisper",
+ whisperModel: "base",
+ });
+
+ assert.deepEqual(startOptions, {
+ useCuda: true,
+ useVulkan: false,
+ fallbackToVulkan: true,
+ });
+ } finally {
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
});
test("a remembered CUDA failure degrades to Vulkan, then both failures to CPU", () => {
@@ -70,10 +142,18 @@ test("a remembered CUDA failure degrades to Vulkan, then both failures to CPU",
const manager = managerWith({ cudaDownloaded: true, vulkanDownloaded: true });
process.env.WHISPER_GPU_FAILED = "cuda";
- assert.deepEqual(manager.resolveGpuStartOptions(), { useCuda: false, useVulkan: true });
+ assert.deepEqual(manager.resolveGpuStartOptions(), {
+ useCuda: false,
+ useVulkan: true,
+ fallbackToVulkan: false,
+ });
process.env.WHISPER_GPU_FAILED = "cuda,vulkan";
- assert.deepEqual(manager.resolveGpuStartOptions(), { useCuda: false, useVulkan: false });
+ assert.deepEqual(manager.resolveGpuStartOptions(), {
+ useCuda: false,
+ useVulkan: false,
+ fallbackToVulkan: false,
+ });
});
test("clearing the failure re-enables the backend (Retry)", () => {
@@ -88,44 +168,76 @@ test("clearing the failure re-enables the backend (Retry)", () => {
test("a downloaded pack with a lost env flag still engages (#1340)", () => {
const vulkanOnly = managerWith({ vulkanDownloaded: true });
- assert.deepEqual(vulkanOnly.resolveGpuStartOptions(), { useCuda: false, useVulkan: true });
+ assert.deepEqual(vulkanOnly.resolveGpuStartOptions(), {
+ useCuda: false,
+ useVulkan: true,
+ fallbackToVulkan: false,
+ });
const cudaOnly = managerWith({ cudaDownloaded: true });
- assert.deepEqual(cudaOnly.resolveGpuStartOptions(), { useCuda: true, useVulkan: false });
+ assert.deepEqual(cudaOnly.resolveGpuStartOptions(), {
+ useCuda: true,
+ useVulkan: false,
+ fallbackToVulkan: false,
+ });
});
test("explicit 'false' opts a downloaded pack out", () => {
process.env.WHISPER_VULKAN_ENABLED = "false";
const vulkanOnly = managerWith({ vulkanDownloaded: true });
- assert.deepEqual(vulkanOnly.resolveGpuStartOptions(), { useCuda: false, useVulkan: false });
+ assert.deepEqual(vulkanOnly.resolveGpuStartOptions(), {
+ useCuda: false,
+ useVulkan: false,
+ fallbackToVulkan: false,
+ });
process.env.WHISPER_CUDA_ENABLED = "false";
const cudaOnly = managerWith({ cudaDownloaded: true });
- assert.deepEqual(cudaOnly.resolveGpuStartOptions(), { useCuda: false, useVulkan: false });
+ assert.deepEqual(cudaOnly.resolveGpuStartOptions(), {
+ useCuda: false,
+ useVulkan: false,
+ fallbackToVulkan: false,
+ });
});
test("the 'false' opt-out is case-insensitive (hand-edited .env)", () => {
// The flag is a hand-edit surface now, so FALSE/False must opt out too.
process.env.WHISPER_VULKAN_ENABLED = "FALSE";
const vulkanOnly = managerWith({ vulkanDownloaded: true });
- assert.deepEqual(vulkanOnly.resolveGpuStartOptions(), { useCuda: false, useVulkan: false });
+ assert.deepEqual(vulkanOnly.resolveGpuStartOptions(), {
+ useCuda: false,
+ useVulkan: false,
+ fallbackToVulkan: false,
+ });
process.env.WHISPER_CUDA_ENABLED = "False";
const cudaOnly = managerWith({ cudaDownloaded: true });
- assert.deepEqual(cudaOnly.resolveGpuStartOptions(), { useCuda: false, useVulkan: false });
+ assert.deepEqual(cudaOnly.resolveGpuStartOptions(), {
+ useCuda: false,
+ useVulkan: false,
+ fallbackToVulkan: false,
+ });
});
test("a remembered failure still gates a flag-less downloaded pack", () => {
process.env.WHISPER_GPU_FAILED = "vulkan";
const manager = managerWith({ vulkanDownloaded: true });
- assert.deepEqual(manager.resolveGpuStartOptions(), { useCuda: false, useVulkan: false });
+ assert.deepEqual(manager.resolveGpuStartOptions(), {
+ useCuda: false,
+ useVulkan: false,
+ fallbackToVulkan: false,
+ });
});
test("without injected binary managers (macOS) everything resolves to CPU", () => {
process.env.WHISPER_CUDA_ENABLED = "true";
process.env.WHISPER_VULKAN_ENABLED = "true";
const manager = new WhisperManager();
- assert.deepEqual(manager.resolveGpuStartOptions(), { useCuda: false, useVulkan: false });
+ assert.deepEqual(manager.resolveGpuStartOptions(), {
+ useCuda: false,
+ useVulkan: false,
+ fallbackToVulkan: false,
+ });
});
test("resolveFailedGpuBackends tolerates empty and messy values", () => {
@@ -260,7 +372,11 @@ test("_warnIfGpuFallback stays quiet for a pack that was never installed or is t
test("_warnIfGpuFallback names Vulkan when an unusable CUDA pack leaves it running", (t) => {
process.env.WHISPER_CUDA_ENABLED = "true";
const manager = managerWith({ cudaNeedsUpdate: true, vulkanDownloaded: true });
- assert.deepEqual(manager.resolveGpuStartOptions(), { useCuda: false, useVulkan: true });
+ assert.deepEqual(manager.resolveGpuStartOptions(), {
+ useCuda: false,
+ useVulkan: true,
+ fallbackToVulkan: false,
+ });
const [warning] = fallbackWarnings(t, manager);
assert.equal(warning.message, "Whisper CUDA enabled but cannot be used, falling back to Vulkan");
@@ -320,6 +436,10 @@ test("pre-warm and every server start check for a GPU fallback before starting",
test("_warnIfGpuFallback ignores an outdated Vulkan pack while CUDA runs", (t) => {
const manager = managerWith({ cudaDownloaded: true, vulkanNeedsUpdate: true });
- assert.deepEqual(manager.resolveGpuStartOptions(), { useCuda: true, useVulkan: false });
+ assert.deepEqual(manager.resolveGpuStartOptions(), {
+ useCuda: true,
+ useVulkan: false,
+ fallbackToVulkan: false,
+ });
assert.deepEqual(fallbackWarnings(t, manager), []);
});
diff --git a/test/helpers/whisperServerGpuFailureReason.test.js b/test/helpers/whisperServerGpuFailureReason.test.js
index 3b28a84416..1b05c119f7 100644
--- a/test/helpers/whisperServerGpuFailureReason.test.js
+++ b/test/helpers/whisperServerGpuFailureReason.test.js
@@ -29,6 +29,8 @@ const BINARY = {
// Binary path -> how its fake process behaves
const behaviours = new Map();
const warnings = [];
+// Every spawn's arguments and environment, newest last
+const spawns = [];
function fakeWhisperServer({ stderr = "", exit = null, launchError = null, healthy = false } = {}) {
const child = new EventEmitter();
@@ -77,7 +79,15 @@ Module._load = function loadWithFakeProcess(request, parent, isMain) {
}
if (parent?.filename === serverModulePath) {
if (request === "child_process") {
- return { ...childProcess, spawn: (binary) => fakeWhisperServer(behaviours.get(binary)) };
+ return {
+ ...childProcess,
+ spawn: (binary, args, options) => {
+ spawns.push({ binary, args, env: options.env });
+ // A behaviour can depend on the arguments (e.g. a --device pin)
+ const behaviour = behaviours.get(binary);
+ return fakeWhisperServer(typeof behaviour === "function" ? behaviour(args) : behaviour);
+ },
+ };
}
if (request === "./debugLogger") {
const ignore = () => {};
@@ -101,6 +111,7 @@ try {
function createManager(t) {
behaviours.clear();
warnings.length = 0;
+ spawns.length = 0;
behaviours.set(BINARY.cpu, { healthy: true });
const manager = new WhisperServerManager();
manager.getServerBinaryPath = (options = {}) =>
@@ -112,6 +123,14 @@ function createManager(t) {
return manager;
}
+// Like the real server, it answers only once the model (and its log) has loaded
+function answerAfterOutput(manager) {
+ manager.checkHealth = async () => {
+ await new Promise((resolve) => setImmediate(resolve));
+ return Boolean(manager.process?.healthy);
+ };
+}
+
function fallbackWarning() {
return warnings.find(({ message }) =>
message.endsWith("whisper-server failed, falling back to CPU")
@@ -127,7 +146,9 @@ test("#1340: a Vulkan server that dies at startup reports the createDevice error
await manager.start(modelPath, { useVulkan: true });
assert.equal(manager.useVulkan, false, "the CPU server took over");
- assert.deepEqual(events, [{ reason: "vk::PhysicalDevice::createDevice: ErrorDeviceLost" }]);
+ assert.deepEqual(events, [
+ { reason: "vk::PhysicalDevice::createDevice: ErrorDeviceLost", fallbackBackend: "cpu" },
+ ]);
const warning = fallbackWarning();
assert.equal(warning.meta.reason, "vk::PhysicalDevice::createDevice: ErrorDeviceLost");
});
@@ -145,10 +166,141 @@ test("a CUDA server that runs out of memory at startup reports the failed alloca
{
reason:
"ggml_backend_cuda_buffer_type_alloc_buffer: allocating 1533.14 MiB on device 0: cudaMalloc failed: out of memory",
+ fallbackBackend: "cpu",
},
]);
});
+test("a CUDA server that comes up without its GPU reports why, not a startup timeout", async (t) => {
+ const cases = [
+ [
+ "ggml_cuda_init: failed to initialize CUDA: no CUDA-capable device is detected\n" +
+ "whisper_backend_init_gpu: no GPU found\n",
+ "ggml_cuda_init: failed to initialize CUDA: no CUDA-capable device is detected",
+ ],
+ [
+ "whisper_backend_init_gpu: no GPU found\n",
+ "server started without initializing its GPU backend",
+ ],
+ ];
+ for (const [stderr, reason] of cases) {
+ const manager = createManager(t);
+ behaviours.set(BINARY.cuda, { stderr, healthy: true });
+ answerAfterOutput(manager);
+ const events = [];
+ manager.on("cuda-fallback", (payload) => events.push(payload));
+
+ await manager.start(modelPath, { useCuda: true });
+
+ assert.equal(manager.useCuda, false);
+ assert.deepEqual(events, [{ reason, fallbackBackend: "cpu" }]);
+ await manager.stop();
+ }
+});
+
+test("a bundled whisper-server built for CUDA reports the GPU it runs on (#2168)", async (t) => {
+ const manager = createManager(t);
+ behaviours.set(BINARY.cpu, {
+ stderr: "whisper_backend_init_gpu: using CUDA0 backend\n",
+ healthy: true,
+ });
+ answerAfterOutput(manager);
+ let bundledGpuEvents = 0;
+ manager.on("bundled-gpu", () => bundledGpuEvents++);
+
+ await manager.start(modelPath, {});
+
+ assert.equal(manager.useCuda, false);
+ assert.equal(manager.getStatus().gpuBackend, "cuda");
+ assert.equal(manager.getStatus().gpuAccelerated, true);
+ assert.equal(bundledGpuEvents, 1);
+});
+
+const VULKAN_DEVICE =
+ "ggml_vulkan: 0 = NVIDIA GeForce RTX 3060 (NVIDIA) | uma: 0 | fp16: 1 | warp size: 32\n";
+const USING_VULKAN = "whisper_backend_init_gpu: using Vulkan0 backend\n";
+const LOADER_ERROR =
+ "whisper-server-cuda: error while loading shared libraries: libcudart.so.12: cannot open shared object file: No such file or directory\n";
+
+test("a CUDA server that fails at startup moves to an installed Vulkan pack", async (t) => {
+ const manager = createManager(t);
+ answerAfterOutput(manager);
+ behaviours.set(BINARY.cuda, { stderr: LOADER_ERROR, exit: { code: 127 } });
+ behaviours.set(BINARY.vulkan, { stderr: VULKAN_DEVICE + USING_VULKAN, healthy: true });
+ const events = [];
+ manager.on("cuda-fallback", (payload) => events.push(["cuda", payload]));
+ manager.on("gpu-fallback", (payload) => events.push(["vulkan", payload]));
+
+ await manager.start(modelPath, { useCuda: true, fallbackToVulkan: true });
+
+ assert.equal(manager.useVulkan, true);
+ assert.equal(manager.getStatus().gpuBackend, "vulkan");
+ assert.equal(manager.gpuFallbackActive, false);
+ assert.deepEqual(events, [["cuda", { reason: LOADER_ERROR.trim(), fallbackBackend: "vulkan" }]]);
+});
+
+test("a CUDA pack that fails lands on a bundled CUDA build: the GPU stays on (#2168)", async (t) => {
+ const manager = createManager(t);
+ answerAfterOutput(manager);
+ behaviours.set(BINARY.cuda, { stderr: LOADER_ERROR, exit: { code: 127 } });
+ behaviours.set(BINARY.cpu, {
+ stderr: "whisper_backend_init_gpu: using CUDA0 backend\n",
+ healthy: true,
+ });
+ const events = [];
+ manager.on("cuda-fallback", (payload) => events.push(payload));
+
+ await manager.start(modelPath, { useCuda: true });
+
+ assert.equal(manager.useCuda, false);
+ assert.deepEqual(events, [{ reason: LOADER_ERROR.trim(), fallbackBackend: "cuda" }]);
+});
+
+test("a Vulkan pin to a device that is gone is cleared, not saved as a GPU failure (#1606)", async (t) => {
+ const manager = createManager(t);
+ answerAfterOutput(manager);
+ // ggml finds no device 1, so whisper.cpp runs on the CPU
+ behaviours.set(BINARY.vulkan, (args) => ({
+ stderr:
+ VULKAN_DEVICE +
+ (args.includes("--device") ? "whisper_backend_init_gpu: no GPU found\n" : USING_VULKAN),
+ healthy: true,
+ }));
+ const events = [];
+ manager.on("gpu-fallback", (payload) => events.push(["gpu-fallback", payload]));
+ manager.on("vulkan-device-pin-cleared", () => events.push(["pin-cleared"]));
+
+ await manager.start(modelPath, { useVulkan: true, vulkanDeviceIndex: 1 });
+
+ assert.deepEqual(events, [["pin-cleared"]]);
+ assert.equal(manager.useVulkan, true);
+ assert.equal(manager.getStatus().gpuBackend, "vulkan");
+ assert.equal(spawns.at(-1).args.includes("--device"), false);
+});
+
+test("a Linux CUDA start puts the pack's own folder first on the library path", async (t) => {
+ const platform = Object.getOwnPropertyDescriptor(process, "platform");
+ Object.defineProperty(process, "platform", { value: "linux" });
+ const libraryPath = process.env.LD_LIBRARY_PATH;
+ process.env.LD_LIBRARY_PATH = "/opt/cuda/lib64";
+ t.after(() => {
+ Object.defineProperty(process, "platform", platform);
+ if (libraryPath === undefined) delete process.env.LD_LIBRARY_PATH;
+ else process.env.LD_LIBRARY_PATH = libraryPath;
+ });
+ const manager = createManager(t);
+ answerAfterOutput(manager);
+ behaviours.set(BINARY.cuda, {
+ stderr: "whisper_backend_init_gpu: using CUDA0 backend\n",
+ healthy: true,
+ });
+
+ await manager.start(modelPath, { useCuda: true });
+
+ assert.equal(spawns.at(-1).env.LD_LIBRARY_PATH, `${path.dirname(BINARY.cuda)}:/opt/cuda/lib64`);
+ assert.equal(manager.getStatus().gpuBackend, "cuda");
+});
+
test("a GPU server that never answers is reported as a startup timeout", async (t) => {
const manager = createManager(t);
// Prints its banner and hangs
@@ -166,7 +318,7 @@ test("a GPU server that never answers is reported as a startup timeout", async (
await manager.start(modelPath, { useVulkan: true });
- assert.deepEqual(events, [{ reason: "startup timed out after 120 s" }]);
+ assert.deepEqual(events, [{ reason: "startup timed out after 120 s", fallbackBackend: "cpu" }]);
});
test("a GPU binary that cannot be launched reports its error, not exit code -2", async (t) => {
@@ -179,7 +331,7 @@ test("a GPU binary that cannot be launched reports its error, not exit code -2",
await manager.start(modelPath, { useCuda: true });
assert.equal(manager.useCuda, false, "the CPU server took over");
- assert.deepEqual(events, [{ reason: "could not launch (ENOENT)" }]);
+ assert.deepEqual(events, [{ reason: "could not launch (ENOENT)", fallbackBackend: "cpu" }]);
});
test("a Vulkan server that crashes on Windows reports the status code in hex", async (t) => {
@@ -194,5 +346,5 @@ test("a Vulkan server that crashes on Windows reports the status code in hex", a
await manager.start(modelPath, { useVulkan: true });
- assert.deepEqual(events, [{ reason: "exit code 0xC0000005" }]);
+ assert.deepEqual(events, [{ reason: "exit code 0xC0000005", fallbackBackend: "cpu" }]);
});
diff --git a/test/helpers/whisperServerGpuGuard.test.js b/test/helpers/whisperServerGpuGuard.test.js
index 69ab627f1d..676a1dfc80 100644
--- a/test/helpers/whisperServerGpuGuard.test.js
+++ b/test/helpers/whisperServerGpuGuard.test.js
@@ -2,7 +2,7 @@ const test = require("node:test");
const assert = require("node:assert/strict");
const WhisperServerManager = require("../../src/helpers/whisperServer");
-const { getGpuSignature } = WhisperServerManager;
+const { getGpuSignature, parseWhisperGpuBackend } = WhisperServerManager;
// start()'s no-op guard must include the GPU backend: before #1458's hotfix a
// running CPU server silently ignored later GPU-enabled start requests
@@ -35,6 +35,28 @@ test("getGpuSignature resolves the backend the way _doStart does", () => {
assert.equal(getGpuSignature({ useCuda: true, useVulkan: true }), "gpu:cuda");
});
+test("GPU status requires whisper.cpp to confirm the active backend", async () => {
+ assert.equal(parseWhisperGpuBackend("whisper_backend_init_gpu: using CUDA0 backend"), "cuda");
+ assert.equal(parseWhisperGpuBackend("whisper_backend_init_gpu: using Vulkan1 backend"), "vulkan");
+ assert.equal(parseWhisperGpuBackend("whisper_backend_init_gpu: no GPU found"), null);
+
+ const manager = new WhisperServerManager();
+ manager.ready = true;
+ manager.process = {};
+ manager.useCuda = true;
+ assert.equal(manager.getStatus().gpuAccelerated, false);
+
+ manager.activeGpuBackend = "cuda";
+ assert.deepEqual(
+ { backend: manager.getStatus().gpuBackend, active: manager.getStatus().gpuAccelerated },
+ { backend: "cuda", active: true }
+ );
+
+ manager.process = null;
+ await manager.stop();
+ assert.equal(manager.getStatus().gpuBackend, null);
+});
+
test("getGpuSignature carries the resolved Vulkan device pin", () => {
assert.equal(getGpuSignature({ useVulkan: true, vulkanDeviceIndex: 1 }), "gpu:vulkan:1");
@@ -94,10 +116,6 @@ function createManager() {
return { manager, doStartCalls };
}
-// Emulate _doStart's real GPU catch: the requested GPU server died during
-// startup, so stop, mark the in-session fallback, and recurse with the
-// corrected CPU flags. failVulkan=false models a working Vulkan pack next to
-// a broken CUDA one.
function emulateGpuStartupFallback(manager, { failVulkan = true } = {}) {
const contractDoStart = manager._doStart;
manager._doStart = async (modelPath, options) => {
@@ -159,24 +177,6 @@ test("a fallback session stays stable: CPU-resolved requests no-op after a GPU->
assert.equal(doStartCalls.length, 1);
});
-test("a fallback session pins CPU: a Vulkan-resolved request no-ops after a CUDA->CPU fallback", async () => {
- const { manager, doStartCalls } = createManager();
- emulateGpuStartupFallback(manager, { failVulkan: false });
-
- await manager.start("/tmp/model.bin", { useCuda: true });
- assert.equal(doStartCalls.length, 1);
- assert.equal(manager.gpuSignature, "gpu:cpu");
- const cpuServer = manager.process;
-
- // Both packs installed: WHISPER_GPU_FAILED only records cuda, so the next
- // dictation resolves to Vulkan. The in-session pin must keep the working
- // CPU server instead of tearing it down for a Vulkan cold start.
- await manager.start("/tmp/model.bin", { useCuda: false, useVulkan: true });
- assert.equal(doStartCalls.length, 1);
- assert.equal(manager.process, cpuServer);
- assert.equal(manager.useVulkan, false);
-});
-
test("an explicit stop() lifts the CPU pin: the next GPU-resolved start engages the GPU", async () => {
const { manager, doStartCalls } = createManager();
emulateGpuStartupFallback(manager, { failVulkan: false });
diff --git a/test/hooks/gpuBannerAvailability.test.js b/test/hooks/gpuBannerAvailability.test.js
index c527b4d713..414ba716c3 100644
--- a/test/hooks/gpuBannerAvailability.test.js
+++ b/test/hooks/gpuBannerAvailability.test.js
@@ -80,3 +80,74 @@ test("closing settings refreshes a stale transcription GPU offer", async (t) =>
assert.equal(cudaProbeCount, 2);
assert.equal(availability.transcription, false);
});
+
+test("a bundled whisper-server already on the GPU takes back the offer once it starts (#2168)", async (t) => {
+ let root = null;
+ t.after(async () => {
+ if (root) await React.act(async () => root.unmount());
+ });
+ let bundledGpu = false;
+ let notifyStatusChanged = null;
+ installBrowserGlobals(t, {
+ window: {
+ electronAPI: {
+ async getCudaWhisperStatus() {
+ return {
+ downloaded: false,
+ bundledGpu,
+ gpuInfo: { hasNvidiaGpu: true, cudaSupported: true },
+ };
+ },
+ onWhisperGpuStatusChanged(callback) {
+ notifyStatusChanged = callback;
+ return () => {
+ notifyStatusChanged = null;
+ };
+ },
+ },
+ },
+ });
+ const container = installHookDom(t);
+ const vite = await createRendererServer(t, {
+ cachePrefix: "openwhispr-gpu-banner-availability-test-",
+ });
+ const { useGpuBannerAvailability } = await vite.ssrLoadModule(
+ "/hooks/useGpuBannerAvailability.ts"
+ );
+
+ const props = {
+ settings: {
+ useLocalWhisper: true,
+ localTranscriptionProvider: "whisper",
+ useCleanupModel: false,
+ cleanupMode: "openwhispr",
+ useDictationAgent: false,
+ dictationAgentMode: "openwhispr",
+ },
+ agentAllowedByPolicy: true,
+ dismissed: false,
+ settingsOpen: false,
+ platform: "linux",
+ };
+ let availability;
+ function Harness() {
+ availability = useGpuBannerAvailability(props);
+ return null;
+ }
+ root = createRoot(container);
+ await React.act(async () => {
+ root.render(React.createElement(Harness));
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ assert.equal(availability.transcription, true, "offered before the server is up");
+
+ // The launch pre-warm comes up on the distro's CUDA build of whisper-server
+ bundledGpu = true;
+ await React.act(async () => {
+ notifyStatusChanged();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ assert.equal(availability.transcription, false);
+});
diff --git a/test/scripts/downloadWhisperCpp.test.js b/test/scripts/downloadWhisperCpp.test.js
index e6f70b91ba..1a17c48907 100644
--- a/test/scripts/downloadWhisperCpp.test.js
+++ b/test/scripts/downloadWhisperCpp.test.js
@@ -32,7 +32,7 @@ test("win32 whisper-server config extracts DLL companions from the release zip",
test("whisper-cpp pin is a release whose win32 zips bundle the MSVC runtime DLLs", () => {
// 0.0.8 and 0.0.9 ship a bare exe; 0.0.10 is the first tag with the DLLs.
- assert.equal(WHISPER_CPP_TAG, "0.0.10");
+ assert.equal(WHISPER_CPP_TAG, "0.0.11");
});
test("all-platform mode reports failure after attempting every configured download", async () => {
diff --git a/test/utils/whisperGpuPack.test.js b/test/utils/whisperGpuPack.test.js
index c485d04e03..0604593258 100644
--- a/test/utils/whisperGpuPack.test.js
+++ b/test/utils/whisperGpuPack.test.js
@@ -84,3 +84,17 @@ test("the pack main reports in use wins over every other rule (#1736)", async ()
// A Vulkan pack on a GPU detection doesn't recognize
assert.equal(pickWhisperGpuBackend(cuda(NO_NVIDIA), vulkan(false, IN_USE)), "vulkan");
});
+
+test("no first-time offer while the bundled whisper-server already runs on the GPU (#2168)", async () => {
+ const { pickWhisperGpuBackend } = await load();
+ const BUNDLED = { bundledGpu: true };
+ assert.equal(pickWhisperGpuBackend(cuda(NVIDIA, BUNDLED), vulkan(true, BUNDLED)), null);
+ assert.equal(pickWhisperGpuBackend(cuda(NO_NVIDIA, BUNDLED), vulkan(true, BUNDLED)), null);
+ // A pack that is installed, failed or outdated keeps its card
+ const installed = { ...BUNDLED, ...DOWNLOADED };
+ assert.equal(pickWhisperGpuBackend(cuda(NVIDIA, installed), vulkan(true, BUNDLED)), "cuda");
+ const failed = { ...BUNDLED, ...DOWNLOADED, inUse: true, gpuFailed: true };
+ assert.equal(pickWhisperGpuBackend(cuda(NVIDIA, failed), vulkan(true, BUNDLED)), "cuda");
+ const outdated = { ...BUNDLED, ...OUTDATED };
+ assert.equal(pickWhisperGpuBackend(cuda(NVIDIA, BUNDLED), vulkan(true, outdated)), "vulkan");
+});