Skip to content

Commit 36df3a8

Browse files
authored
feat(whisper): Vulkan GPU acceleration for AMD/Intel GPUs (#1185)
Wire in the whisper-server Vulkan binaries shipped since binaries release 0.0.7 so AMD Radeon and Intel Arc/iGPU users on Windows/Linux get the same one-click "Enable GPU" flow NVIDIA users get with CUDA. - Extract src/helpers/gpuBinaryManager.js: the shared download/extract/ install pipeline behind whisperCudaManager and llamaVulkanManager (both are now thin config subclasses), with streaming SHA256 verification against in-app pinned digests (fail closed) or the GitHub API asset digest. - Add whisperVulkanManager: pinned tag 0.0.8 + pinned digests, statically linked binaries, no companion libs. - whisperServer: preferVulkan binary resolution, sticky useVulkan flag, 120s Vulkan startup timeout, and CPU fallback on ANY Vulkan startup rejection (early exit, late VRAM-OOM death, or hang). CUDA keeps its existing early-exit rule. An intentional stop() during a pending startup now rejects instead of resurrecting the server. - Thread useVulkan through pre-warm, per-dictation starts, and wake re-warm (which now replays lastStartOptions instead of hardcoding CUDA). - IPC: get/download/cancel/delete vulkan-whisper channels (download and delete stop the server first to avoid Windows file locks; CUDA delete now does the same), WHISPER_VULKAN_ENABLED persisted, gpu-fallback relay beside the existing cuda-fallback one. - UI: the CUDA card in TranscriptionModelPicker is now a GPU card that offers CUDA on NVIDIA machines and Vulkan otherwise; ControlPanel's unified GPU banner detects whisper-vulkan too. Reuses the existing gpu.* i18n keys. - Characterization tests for the shared pipeline: asset resolution, digest fail-closed, divergent cancel semantics, cleanup on failure. Deliberate behavior changes kept from the refactor: the CUDA disk pre-check tightens 2x -> 2.5x, its release fetch now sends GITHUB_TOKEN when set (rate limits), and download archives stage in the OS temp dir instead of userData/bin.
1 parent fe23bb1 commit 36df3a8

15 files changed

Lines changed: 932 additions & 454 deletions

main.js

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@ const WindowsKeyManager = require("./src/helpers/windowsKeyManager");
279279
const LinuxKeyManager = require("./src/helpers/linuxKeyManager");
280280
const TextEditMonitor = require("./src/helpers/textEditMonitor");
281281
const WhisperCudaManager = require("./src/helpers/whisperCudaManager");
282+
const WhisperVulkanManager = require("./src/helpers/whisperVulkanManager");
282283
const GoogleCalendarManager = require("./src/helpers/googleCalendarManager");
283284
const MeetingProcessDetector = require("./src/helpers/meetingProcessDetector");
284285
const AudioActivityDetector = require("./src/helpers/audioActivityDetector");
@@ -309,6 +310,7 @@ let windowsKeyManager = null;
309310
let linuxKeyManager = null;
310311
let textEditMonitor = null;
311312
let whisperCudaManager = null;
313+
let whisperVulkanManager = null;
312314
let googleCalendarManager = null;
313315
let meetingDetectionEngine = null;
314316
let audioTapManager = null;
@@ -391,6 +393,7 @@ function initializeCoreManagers() {
391393
whisperManager = new WhisperManager();
392394
if (process.platform !== "darwin") {
393395
whisperCudaManager = new WhisperCudaManager();
396+
whisperVulkanManager = new WhisperVulkanManager();
394397
}
395398
parakeetManager = new ParakeetManager();
396399
diarizationManager = new DiarizationManager();
@@ -435,6 +438,7 @@ function initializeCoreManagers() {
435438
linuxKeyManager,
436439
textEditMonitor,
437440
whisperCudaManager,
441+
whisperVulkanManager,
438442
googleCalendarManager,
439443
meetingDetectionEngine,
440444
audioTapManager,
@@ -958,11 +962,16 @@ async function startApp() {
958962
}, WHISPER_WAKE_REWARM_DELAY_MS);
959963
});
960964

961-
// Non-blocking server pre-warming
965+
// Non-blocking server pre-warming. CUDA wins when both GPU backends are enabled.
966+
const useCuda = process.env.WHISPER_CUDA_ENABLED === "true" && whisperCudaManager?.isDownloaded();
962967
const whisperSettings = {
963968
localTranscriptionProvider: process.env.LOCAL_TRANSCRIPTION_PROVIDER || "",
964969
whisperModel: process.env.LOCAL_WHISPER_MODEL,
965-
useCuda: process.env.WHISPER_CUDA_ENABLED === "true" && whisperCudaManager?.isDownloaded(),
970+
useCuda,
971+
useVulkan:
972+
!useCuda &&
973+
process.env.WHISPER_VULKAN_ENABLED === "true" &&
974+
whisperVulkanManager?.isDownloaded(),
966975
};
967976
whisperManager.initializeAtStartup(whisperSettings).catch((err) => {
968977
debugLogger.debug("Whisper startup init error (non-fatal)", { error: err.message });

preload.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,20 @@ contextBridge.exposeInMainWorld("electronAPI", {
274274
(callback) => () => callback()
275275
),
276276

277+
// Vulkan GPU acceleration (whisper on AMD/Intel GPUs)
278+
getVulkanWhisperStatus: () => ipcRenderer.invoke("get-vulkan-whisper-status"),
279+
downloadVulkanWhisperBinary: () => ipcRenderer.invoke("download-vulkan-whisper-binary"),
280+
cancelVulkanWhisperDownload: () => ipcRenderer.invoke("cancel-vulkan-whisper-download"),
281+
deleteVulkanWhisperBinary: () => ipcRenderer.invoke("delete-vulkan-whisper-binary"),
282+
onVulkanWhisperDownloadProgress: registerListener(
283+
"vulkan-whisper-download-progress",
284+
(callback) => (_event, data) => callback(data)
285+
),
286+
onGpuFallbackNotification: registerListener(
287+
"gpu-fallback-notification",
288+
(callback) => () => callback()
289+
),
290+
277291
// Local Parakeet (NVIDIA) functions
278292
transcribeLocalParakeet: (audioBlob, options) =>
279293
ipcRenderer.invoke("transcribe-local-parakeet", audioBlob, options),

src/components/ControlPanel.tsx

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,12 @@ export default function ControlPanel({ initialSettingsSection }: ControlPanelPro
9999
folderId: number;
100100
event: any;
101101
} | null>(null);
102-
const [gpuAccelAvailable, setGpuAccelAvailable] = useState<{ cuda: boolean; vulkan: boolean }>({
103-
cuda: false,
104-
vulkan: false,
102+
const [gpuAccelAvailable, setGpuAccelAvailable] = useState<{
103+
transcription: boolean;
104+
intelligence: boolean;
105+
}>({
106+
transcription: false,
107+
intelligence: false,
105108
});
106109
const [gpuBannerDismissed, setGpuBannerDismissed] = useState(
107110
() => localStorage.getItem("gpuBannerDismissedUnified") === "true"
@@ -292,11 +295,16 @@ export default function ControlPanel({ initialSettingsSection }: ControlPanelPro
292295
useEffect(() => {
293296
if (platform === "darwin" || gpuBannerDismissed) return;
294297
const detect = async () => {
295-
const results = { cuda: false, vulkan: false };
298+
const results = { transcription: false, intelligence: false };
296299
if (useLocalWhisper && localTranscriptionProvider === "whisper") {
297300
try {
298301
const status = await window.electronAPI?.getCudaWhisperStatus?.();
299-
if (status?.gpuInfo.hasNvidiaGpu && !status.downloaded) results.cuda = true;
302+
if (status?.gpuInfo.hasNvidiaGpu) {
303+
if (!status.downloaded) results.transcription = true;
304+
} else {
305+
const vulkan = await window.electronAPI?.getVulkanWhisperStatus?.();
306+
if (vulkan?.vulkan.available && !vulkan.downloaded) results.transcription = true;
307+
}
300308
} catch {}
301309
}
302310
if (useCleanupModel) {
@@ -305,7 +313,7 @@ export default function ControlPanel({ initialSettingsSection }: ControlPanelPro
305313
window.electronAPI?.detectVulkanGpu?.(),
306314
window.electronAPI?.getLlamaVulkanStatus?.(),
307315
]);
308-
if (gpu?.available && !vulkan?.downloaded) results.vulkan = true;
316+
if (gpu?.available && !vulkan?.downloaded) results.intelligence = true;
309317
} catch {}
310318
}
311319
setGpuAccelAvailable(results);
@@ -837,7 +845,7 @@ export default function ControlPanel({ initialSettingsSection }: ControlPanelPro
837845
</div>
838846
</div>
839847
)}
840-
{(gpuAccelAvailable.cuda || gpuAccelAvailable.vulkan) &&
848+
{(gpuAccelAvailable.transcription || gpuAccelAvailable.intelligence) &&
841849
activeView === "home" &&
842850
!gpuBannerDismissed && (
843851
<div className="max-w-3xl mx-auto w-full mb-3">
@@ -860,7 +868,7 @@ export default function ControlPanel({ initialSettingsSection }: ControlPanelPro
860868
className="h-7 text-xs"
861869
onClick={() => {
862870
setSettingsSection(
863-
gpuAccelAvailable.cuda ? "transcription" : "intelligence"
871+
gpuAccelAvailable.transcription ? "transcription" : "intelligence"
864872
);
865873
setShowSettings(true);
866874
}}

src/components/TranscriptionModelPicker.tsx

Lines changed: 58 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ import { createExternalLinkHandler } from "../utils/externalLinks";
3030
import { API_ENDPOINTS, normalizeBaseUrl } from "../config/constants";
3131
import { GetApiKeyLink } from "./ui/GetApiKeyLink";
3232
import { getCachedPlatform } from "../utils/platform";
33-
import type { CudaWhisperStatus } from "../types/electron";
3433
import logger from "../utils/logger";
3534

3635
interface LocalModel {
@@ -365,14 +364,15 @@ export default function TranscriptionModelPicker({
365364
const [internalLocalProvider, setInternalLocalProvider] = useState(selectedLocalProvider);
366365
const hasLoadedRef = useRef(false);
367366
const hasLoadedParakeetRef = useRef(false);
368-
const [cudaStatus, setCudaStatus] = useState<CudaWhisperStatus | null>(null);
369-
const [cudaDownloading, setCudaDownloading] = useState(false);
370-
const [cudaProgress, setCudaProgress] = useState<DownloadProgress>({
367+
const [gpuBackend, setGpuBackend] = useState<"cuda" | "vulkan" | null>(null);
368+
const [gpuDownloaded, setGpuDownloaded] = useState(false);
369+
const [gpuDownloading, setGpuDownloading] = useState(false);
370+
const [gpuProgress, setGpuProgress] = useState<DownloadProgress>({
371371
downloadedBytes: 0,
372372
totalBytes: 0,
373373
percentage: 0,
374374
});
375-
const [cudaDismissed, setCudaDismissed] = useState(false);
375+
const [gpuDismissed, setGpuDismissed] = useState(false);
376376

377377
useEffect(() => {
378378
if (selectedLocalProvider !== internalLocalProvider) {
@@ -537,42 +537,58 @@ export default function TranscriptionModelPicker({
537537
useEffect(() => {
538538
if (!effectiveLocal || internalLocalProvider !== "whisper") return;
539539
if (getCachedPlatform() === "darwin") return;
540-
window.electronAPI
541-
?.getCudaWhisperStatus?.()
542-
?.then(setCudaStatus)
543-
.catch(() => {});
540+
const detect = async () => {
541+
try {
542+
const cuda = await window.electronAPI?.getCudaWhisperStatus?.();
543+
if (cuda?.gpuInfo.hasNvidiaGpu) {
544+
setGpuBackend("cuda");
545+
setGpuDownloaded(cuda.downloaded);
546+
return;
547+
}
548+
const vulkan = await window.electronAPI?.getVulkanWhisperStatus?.();
549+
if (vulkan?.vulkan.available) {
550+
setGpuBackend("vulkan");
551+
setGpuDownloaded(vulkan.downloaded);
552+
}
553+
} catch {}
554+
};
555+
detect();
544556
}, [effectiveLocal, internalLocalProvider]);
545557

546558
useEffect(() => {
547-
if (!cudaDownloading) return;
548-
const cleanup = window.electronAPI?.onCudaDownloadProgress?.((data) => {
549-
setCudaProgress(data);
550-
});
551-
return cleanup;
552-
}, [cudaDownloading]);
553-
554-
const handleCudaDownload = async () => {
555-
setCudaDownloading(true);
559+
if (!gpuDownloading || !gpuBackend) return;
560+
const subscribe =
561+
gpuBackend === "cuda"
562+
? window.electronAPI?.onCudaDownloadProgress
563+
: window.electronAPI?.onVulkanWhisperDownloadProgress;
564+
return subscribe?.((data) => setGpuProgress(data));
565+
}, [gpuDownloading, gpuBackend]);
566+
567+
const handleGpuDownload = async () => {
568+
setGpuDownloading(true);
556569
try {
557-
const result = await window.electronAPI?.downloadCudaWhisperBinary?.();
558-
if (result?.success) {
559-
const status = await window.electronAPI?.getCudaWhisperStatus?.();
560-
setCudaStatus(status || null);
561-
}
570+
const result =
571+
gpuBackend === "cuda"
572+
? await window.electronAPI?.downloadCudaWhisperBinary?.()
573+
: await window.electronAPI?.downloadVulkanWhisperBinary?.();
574+
if (result?.success) setGpuDownloaded(true);
562575
} finally {
563-
setCudaDownloading(false);
576+
setGpuDownloading(false);
564577
}
565578
};
566579

567-
const handleCudaDelete = async () => {
568-
await window.electronAPI?.deleteCudaWhisperBinary?.();
569-
const status = await window.electronAPI?.getCudaWhisperStatus?.();
570-
setCudaStatus(status || null);
580+
const handleGpuDelete = async () => {
581+
const result =
582+
gpuBackend === "cuda"
583+
? await window.electronAPI?.deleteCudaWhisperBinary?.()
584+
: await window.electronAPI?.deleteVulkanWhisperBinary?.();
585+
if (result?.success) setGpuDownloaded(false);
571586
};
572587

573-
const handleCudaCancel = async () => {
574-
await window.electronAPI?.cancelCudaWhisperDownload?.();
575-
setCudaDownloading(false);
588+
const handleGpuCancel = async () => {
589+
if (gpuBackend === "cuda") await window.electronAPI?.cancelCudaWhisperDownload?.();
590+
else await window.electronAPI?.cancelVulkanWhisperDownload?.();
591+
setGpuDownloading(false);
576592
};
577593

578594
const {
@@ -1057,34 +1073,33 @@ export default function TranscriptionModelPicker({
10571073

10581074
{progressDisplay}
10591075

1060-
{cudaDownloading && internalLocalProvider === "whisper" && (
1076+
{gpuDownloading && internalLocalProvider === "whisper" && (
10611077
<div>
1062-
<DownloadProgressBar modelName="GPU acceleration" progress={cudaProgress} />
1078+
<DownloadProgressBar modelName="GPU acceleration" progress={gpuProgress} />
10631079
<div className="px-2.5 pb-1 flex justify-end">
10641080
<button
1065-
onClick={handleCudaCancel}
1081+
onClick={handleGpuCancel}
10661082
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
10671083
>
1068-
Cancel
1084+
{t("gpu.cancel")}
10691085
</button>
10701086
</div>
10711087
</div>
10721088
)}
10731089

10741090
{internalLocalProvider === "whisper" &&
1075-
!cudaDismissed &&
1076-
!cudaDownloading &&
1077-
getCachedPlatform() !== "darwin" &&
1078-
cudaStatus?.gpuInfo.hasNvidiaGpu && (
1091+
!gpuDismissed &&
1092+
!gpuDownloading &&
1093+
gpuBackend && (
10791094
<div className="rounded-md border border-border bg-surface-1 p-2.5">
1080-
{cudaStatus.downloaded ? (
1095+
{gpuDownloaded ? (
10811096
<div className="flex items-center justify-between">
10821097
<div className="flex items-center gap-1.5">
10831098
<Check size={13} className="text-success" />
10841099
<span className="text-xs font-medium text-foreground">{t("gpu.active")}</span>
10851100
</div>
10861101
<Button
1087-
onClick={handleCudaDelete}
1102+
onClick={handleGpuDelete}
10881103
size="sm"
10891104
variant="ghost"
10901105
className="h-6 px-2 text-xs text-muted-foreground hover:text-destructive"
@@ -1101,15 +1116,15 @@ export default function TranscriptionModelPicker({
11011116
</p>
11021117
<div className="flex items-center gap-2 mt-1.5">
11031118
<Button
1104-
onClick={handleCudaDownload}
1119+
onClick={handleGpuDownload}
11051120
size="sm"
11061121
variant="default"
11071122
className="h-6 px-2.5 text-xs"
11081123
>
11091124
{t("gpu.enableButton")}
11101125
</Button>
11111126
<button
1112-
onClick={() => setCudaDismissed(true)}
1127+
onClick={() => setGpuDismissed(true)}
11131128
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
11141129
>
11151130
{t("gpu.dismiss")}

src/helpers/environment.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ const PERSISTED_KEYS = [
4545
"START_MINIMIZED",
4646
"UI_LANGUAGE",
4747
"WHISPER_CUDA_ENABLED",
48+
"WHISPER_VULKAN_ENABLED",
4849
"WHISPER_THREADS",
4950
"TRANSCRIPTION_GPU_UUID",
5051
"INTELLIGENCE_GPU_UUID",

0 commit comments

Comments
 (0)