Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@
- [🤗 Contributing](#-contributing)
- [🎗 License](#-license)

See also [docs/hardware-selection.md](docs/hardware-selection.md) for the
new Hardware section under Settings → Advanced (CPU / GPU selector) and
[docs/building-whisper-cuda-windows.md](docs/building-whisper-cuda-windows.md)
for building the CUDA whisper binary on Windows.

## ⬇️ Download

<p>
Expand Down
13 changes: 13 additions & 0 deletions apps/desktop/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,19 @@ export interface AppSettingsData {
followed: boolean; // Whether user followed recommendation
};
};
compute?: {
/**
* Compute device preference for local speech models.
* - "auto" (default): the native loader picks the best available backend.
* - "cpu": force the CPU-only build even if a GPU binary is shipped.
* - "gpu": use the GPU backend compiled for the host. `gpuDevice` selects
* which adapter inside that backend (0-based; CUDA/Vulkan/Metal index).
*/
device: "auto" | "cpu" | "gpu";
gpuDevice?: number;
/** Optional thread count for CPU inference; undefined = whisper.cpp default. */
threads?: number;
};
updateChannel?: "stable" | "beta";
featureFlags?: {
flags?: Record<string, string | boolean>;
Expand Down
24 changes: 24 additions & 0 deletions apps/desktop/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,30 @@
"description": "Choose your preferred color scheme"
}
},
"hardware": {
"title": "Hardware",
"description": "Select which device runs local speech models (e.g. Whisper). Changes take effect on the next transcription.",
"computeDevice": {
"label": "Compute device",
"description": "Auto lets Amical pick the best available backend for your machine. Choose CPU to disable GPU acceleration, or pick a specific GPU to force it.",
"options": {
"auto": "Auto (recommended)",
"cpu": "CPU only",
"unavailable": "GPU #{{index}} (not detected)"
}
},
"detected": {
"title": "Detected hardware",
"refresh": "Refresh",
"emptyGpus": "No GPUs detected on this system.",
"backends": "Available backends",
"dedicated": "Dedicated",
"integrated": "Integrated"
},
"warnings": {
"noGpuBackend": "This build of Amical does not ship a GPU-accelerated Whisper binary. The selected GPU will be used only if a matching backend (CUDA / Vulkan / Metal) is available at runtime; otherwise transcription will fall back to CPU."
}
},
"advanced": {
"title": "Advanced",
"description": "Advanced configuration options and experimental features",
Expand Down
24 changes: 24 additions & 0 deletions apps/desktop/src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,30 @@
"description": "Elige tu esquema de colores preferido"
}
},
"hardware": {
"title": "Hardware",
"description": "Elige qué dispositivo ejecuta los modelos de voz locales (p. ej. Whisper). Los cambios se aplican en la siguiente transcripción.",
"computeDevice": {
"label": "Dispositivo de cómputo",
"description": "Automático deja que Amical elija el mejor backend disponible. Elige CPU para desactivar la aceleración por GPU, o selecciona una GPU concreta para forzarla.",
"options": {
"auto": "Automático (recomendado)",
"cpu": "Solo CPU",
"unavailable": "GPU #{{index}} (no detectada)"
}
},
"detected": {
"title": "Hardware detectado",
"refresh": "Actualizar",
"emptyGpus": "No se han detectado GPUs en este sistema.",
"backends": "Backends disponibles",
"dedicated": "Dedicada",
"integrated": "Integrada"
},
"warnings": {
"noGpuBackend": "Esta build de Amical no incluye un binario de Whisper acelerado por GPU. La GPU seleccionada solo se usará si hay un backend compatible (CUDA / Vulkan / Metal) disponible en tiempo de ejecución; si no, la transcripción caerá a CPU."
}
},
"advanced": {
"title": "Avanzado",
"description": "Opciones de configuración avanzada y funciones experimentales",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,20 @@ import {
} from "../../core/pipeline-types";
import { logger } from "../../../main/logger";
import { ModelService } from "../../../services/model-service";
import { SettingsService } from "../../../services/settings-service";
import { SimpleForkWrapper } from "./simple-fork-wrapper";
import * as path from "path";
import { app } from "electron";
import { AppError, ErrorCodes } from "../../../types/error";
import { extractSpeechFromVad } from "../../utils/vad-audio-filter";
import type { WhisperInitOptions } from "./whisper-worker-fork";
import { buildWhisperPrompt } from "./whisper-prompt";

export class WhisperProvider implements TranscriptionProvider {
readonly name = "whisper-local";

private modelService: ModelService;
private settingsService: SettingsService | null;
private workerWrapper: SimpleForkWrapper | null = null;

// Frame aggregation state
Expand Down Expand Up @@ -50,8 +53,33 @@ export class WhisperProvider implements TranscriptionProvider {
private readonly SAMPLE_RATE = 16000;
private readonly SPEECH_PROBABILITY_THRESHOLD = 0.2; // Threshold for speech detection

constructor(modelService: ModelService) {
constructor(modelService: ModelService, settingsService?: SettingsService) {
this.modelService = modelService;
this.settingsService = settingsService ?? null;
}

private async resolveInitOptions(): Promise<WhisperInitOptions> {
if (!this.settingsService) return {};
try {
const compute = await this.settingsService.getComputeSettings();
if (compute.device === "cpu") {
return { preferredBackend: "cpu", gpu: false, threads: compute.threads };
}
if (compute.device === "gpu") {
return {
gpu: true,
gpuDevice: compute.gpuDevice,
threads: compute.threads,
};
}
return { threads: compute.threads };
} catch (error) {
logger.transcription.warn(
"Failed to load compute settings; falling back to auto:",
error,
);
return {};
}
}

/**
Expand Down Expand Up @@ -327,7 +355,8 @@ export class WhisperProvider implements TranscriptionProvider {
}

try {
await this.workerWrapper.exec("initializeModel", [modelPath]);
const initOptions = await this.resolveInitOptions();
await this.workerWrapper.exec("initializeModel", [modelPath, initOptions]);
} catch (error) {
logger.transcription.error(`Failed to initialize:`, error);
// Re-throw AppError as-is, wrap other errors
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
// Worker process entry point for fork
import { Whisper, getLoadedBindingInfo } from "@amical/whisper-wrapper";
import {
Whisper,
getLoadedBindingInfo,
type WhisperBackend,
} from "@amical/whisper-wrapper";
import { shouldDropSegment } from "../../utils/segment-filter";

export interface WhisperInitOptions {
preferredBackend?: WhisperBackend;
gpu?: boolean;
gpuDevice?: number;
flashAttn?: boolean;
threads?: number;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Type definitions for IPC communication
interface WorkerMessage {
id: number;
Expand Down Expand Up @@ -51,29 +63,80 @@ const logger = {

let whisperInstance: Whisper | null = null;
let currentModelPath: string | null = null;
let currentInitOptions: WhisperInitOptions | null = null;

function sameInitOptions(
a: WhisperInitOptions | null,
b: WhisperInitOptions | null,
): boolean {
if (a === b) return true;
if (!a || !b) return false;
return (
a.preferredBackend === b.preferredBackend &&
a.gpu === b.gpu &&
a.gpuDevice === b.gpuDevice &&
a.flashAttn === b.flashAttn &&
a.threads === b.threads
);
}

// Worker methods
const methods = {
async initializeModel(modelPath: string): Promise<void> {
if (whisperInstance && currentModelPath === modelPath) {
return; // Already initialized with same model
async initializeModel(
modelPath: string,
initOptions?: WhisperInitOptions,
): Promise<void> {
const opts: WhisperInitOptions = initOptions ?? {};
if (
whisperInstance &&
currentModelPath === modelPath &&
sameInitOptions(currentInitOptions, opts)
) {
return; // Already initialized with same model and options
}

// Cleanup existing instance
// Dispose the previous instance first. Keep the globals consistent even
// if this fails (e.g. free() throws) so a later init starts from scratch.
if (whisperInstance) {
await whisperInstance.free();
const stale = whisperInstance;
whisperInstance = null;
currentModelPath = null;
currentInitOptions = null;
try {
await stale.free();
} catch (e) {
logger.transcription.warn(
"Failed to free previous Whisper instance:",
e,
);
}
}

whisperInstance = new Whisper(modelPath, { gpu: true });
const candidate = new Whisper(modelPath, {
gpu: opts.gpu,
gpuDevice: opts.gpuDevice,
flashAttn: opts.flashAttn,
threads: opts.threads,
preferredBackend: opts.preferredBackend,
});
try {
await whisperInstance.load();
await candidate.load();
} catch (e) {
// Release the native context we just allocated so we do not leak it.
try {
await candidate.free();
} catch {
/* best-effort cleanup */
}
logger.transcription.error("Failed to load Whisper model:", e);
throw e;
}

// Commit state only after a successful load.
whisperInstance = candidate;
currentModelPath = modelPath;
logger.transcription.info(`Initialized with model: ${modelPath}`);
currentInitOptions = opts;
logger.transcription.info(`Initialized with model: ${modelPath}`, opts);
},

async transcribeAudio(
Expand Down Expand Up @@ -142,6 +205,7 @@ const methods = {
await whisperInstance.free();
whisperInstance = null;
currentModelPath = null;
currentInitOptions = null;
}
},

Expand Down
Loading