Skip to content
Open
4 changes: 4 additions & 0 deletions preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,10 @@ contextBridge.exposeInMainWorld("electronAPI", {
saveMistralKey: (key) => ipcRenderer.invoke("save-mistral-key", key),
proxyMistralTranscription: (data) => ipcRenderer.invoke("proxy-mistral-transcription", data),

// Smallest AI API
getSmallestAiKey: () => ipcRenderer.invoke("get-smallest-ai-key"),
saveSmallestAiKey: (key) => ipcRenderer.invoke("save-smallest-ai-key", key),

// Custom endpoint API keys
getCustomTranscriptionKey: () => ipcRenderer.invoke("get-custom-transcription-key"),
saveCustomTranscriptionKey: (key) => ipcRenderer.invoke("save-custom-transcription-key", key),
Expand Down
Binary file added src/assets/icons/providers/smallest-ai.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions src/components/OnboardingFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
openaiApiKey,
groqApiKey,
mistralApiKey,
smallestAiApiKey,
customTranscriptionApiKey,
setCustomTranscriptionApiKey,
dictationKey,
Expand All @@ -94,6 +95,7 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
setOpenaiApiKey,
setGroqApiKey,
setMistralApiKey,
setSmallestAiApiKey,
updateTranscriptionSettings,
preferredLanguage,
} = useSettings();
Expand Down Expand Up @@ -510,6 +512,8 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
setGroqApiKey={setGroqApiKey}
mistralApiKey={mistralApiKey}
setMistralApiKey={setMistralApiKey}
smallestAiApiKey={smallestAiApiKey}
setSmallestAiApiKey={setSmallestAiApiKey}
customTranscriptionApiKey={customTranscriptionApiKey}
setCustomTranscriptionApiKey={setCustomTranscriptionApiKey}
cloudTranscriptionBaseUrl={cloudTranscriptionBaseUrl}
Expand Down Expand Up @@ -670,6 +674,8 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
return groqApiKey.trim().length > 0;
} else if (cloudTranscriptionProvider === "mistral") {
return mistralApiKey.trim().length > 0;
} else if (cloudTranscriptionProvider === "smallest") {
return smallestAiApiKey.trim().length > 0;
} else if (cloudTranscriptionProvider === "custom") {
// Custom can work without API key for local endpoints
return true;
Expand Down
10 changes: 10 additions & 0 deletions src/components/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ interface TranscriptionSectionProps {
setGroqApiKey: (key: string) => void;
mistralApiKey: string;
setMistralApiKey: (key: string) => void;
smallestAiApiKey: string;
setSmallestAiApiKey: (key: string) => void;
customTranscriptionApiKey: string;
setCustomTranscriptionApiKey: (key: string) => void;
cloudTranscriptionBaseUrl?: string;
Expand Down Expand Up @@ -238,6 +240,8 @@ function TranscriptionSection({
setGroqApiKey,
mistralApiKey,
setMistralApiKey,
smallestAiApiKey,
setSmallestAiApiKey,
customTranscriptionApiKey,
setCustomTranscriptionApiKey,
cloudTranscriptionBaseUrl,
Expand Down Expand Up @@ -357,6 +361,8 @@ function TranscriptionSection({
setGroqApiKey={setGroqApiKey}
mistralApiKey={mistralApiKey}
setMistralApiKey={setMistralApiKey}
smallestAiApiKey={smallestAiApiKey}
setSmallestAiApiKey={setSmallestAiApiKey}
customTranscriptionApiKey={customTranscriptionApiKey}
setCustomTranscriptionApiKey={setCustomTranscriptionApiKey}
cloudTranscriptionBaseUrl={cloudTranscriptionBaseUrl}
Expand Down Expand Up @@ -789,6 +795,7 @@ export default function SettingsPage({
geminiApiKey,
groqApiKey,
mistralApiKey,
smallestAiApiKey,
dictationKey,
activationMode,
setActivationMode,
Expand All @@ -813,6 +820,7 @@ export default function SettingsPage({
setGeminiApiKey,
setGroqApiKey,
setMistralApiKey,
setSmallestAiApiKey,
customTranscriptionApiKey,
setCustomTranscriptionApiKey,
customReasoningApiKey,
Expand Down Expand Up @@ -3208,6 +3216,8 @@ EOF`,
setGroqApiKey={setGroqApiKey}
mistralApiKey={mistralApiKey}
setMistralApiKey={setMistralApiKey}
smallestAiApiKey={smallestAiApiKey}
setSmallestAiApiKey={setSmallestAiApiKey}
customTranscriptionApiKey={customTranscriptionApiKey}
setCustomTranscriptionApiKey={setCustomTranscriptionApiKey}
cloudTranscriptionBaseUrl={cloudTranscriptionBaseUrl}
Expand Down
24 changes: 18 additions & 6 deletions src/components/TranscriptionModelPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,8 @@ interface TranscriptionModelPickerProps {
setGroqApiKey: (key: string) => void;
mistralApiKey: string;
setMistralApiKey: (key: string) => void;
smallestAiApiKey: string;
setSmallestAiApiKey: (key: string) => void;
customTranscriptionApiKey?: string;
setCustomTranscriptionApiKey?: (key: string) => void;
cloudTranscriptionBaseUrl?: string;
Expand All @@ -209,6 +211,7 @@ const CLOUD_PROVIDER_TABS = [
{ id: "openai", name: "OpenAI" },
{ id: "groq", name: "Groq" },
{ id: "mistral", name: "Mistral" },
{ id: "smallest", name: "Smallest AI" },
{ id: "custom", name: "Custom" },
];

Expand Down Expand Up @@ -272,6 +275,8 @@ export default function TranscriptionModelPicker({
setGroqApiKey,
mistralApiKey,
setMistralApiKey,
smallestAiApiKey,
setSmallestAiApiKey,
customTranscriptionApiKey = "",
setCustomTranscriptionApiKey,
cloudTranscriptionBaseUrl = "",
Expand Down Expand Up @@ -878,6 +883,7 @@ export default function TranscriptionModelPicker({
groq: "https://console.groq.com/keys",
mistral: "https://console.mistral.ai/api-keys",
openai: "https://platform.openai.com/api-keys",
smallest: "https://app.smallest.ai/",
}[selectedCloudProvider] || "https://platform.openai.com/api-keys"
)}
className="text-xs text-primary/70 hover:text-primary transition-colors cursor-pointer"
Expand All @@ -887,14 +893,20 @@ export default function TranscriptionModelPicker({
</div>
<ApiKeyInput
apiKey={
{ groq: groqApiKey, mistral: mistralApiKey, openai: openaiApiKey }[
selectedCloudProvider
] || openaiApiKey
{
groq: groqApiKey,
mistral: mistralApiKey,
openai: openaiApiKey,
smallest: smallestAiApiKey,
}[selectedCloudProvider] || openaiApiKey
}
setApiKey={
{ groq: setGroqApiKey, mistral: setMistralApiKey, openai: setOpenaiApiKey }[
selectedCloudProvider
] || setOpenaiApiKey
{
groq: setGroqApiKey,
mistral: setMistralApiKey,
openai: setOpenaiApiKey,
smallest: setSmallestAiApiKey,
}[selectedCloudProvider] || setOpenaiApiKey
}
label=""
helpText=""
Expand Down
53 changes: 53 additions & 0 deletions src/helpers/audioManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -854,6 +854,18 @@ registerProcessor("pcm-streaming-processor", PCMStreamingProcessor);
err.code = "API_KEY_MISSING";
throw err;
}
} else if (provider === "smallest") {
apiKey = s.smallestAiApiKey;
if (!isValidApiKey(apiKey, "smallest")) {
apiKey = await window.electronAPI.getSmallestAiKey?.();
}
if (!isValidApiKey(apiKey, "smallest")) {
const err = new Error(
"Smallest AI API key not found. Please set your API key in the Control Panel."
);
err.code = "API_KEY_MISSING";
throw err;
}
} else {
// Default to OpenAI
// Prefer store value (user-entered via UI) over main process (.env)
Expand Down Expand Up @@ -1475,6 +1487,42 @@ registerProcessor("pcm-streaming-processor", PCMStreamingProcessor);
throw new Error("No text transcribed - Mistral response was empty");
}

// Smallest AI uses raw binary audio (application/octet-stream), not multipart/form-data
if (provider === "smallest") {
const audioBuffer = await optimizedAudio.arrayBuffer();
const params = new URLSearchParams({ language: language || "en" });
const smallestHeaders = {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/octet-stream",
"X-Source": "openwhispr",
};
const smallestResponse = await fetch(`${endpoint}?${params}`, {
method: "POST",
headers: smallestHeaders,
body: audioBuffer,
});

if (!smallestResponse.ok) {
const errorText = await smallestResponse.text();
logger.error("Transcription API error response", { status: smallestResponse.status, errorText }, "transcription");
throw new Error(`API Error: ${smallestResponse.status} ${errorText}`);
}

const smallestData = await smallestResponse.json();
const rawText = smallestData?.transcription || "";

if (!rawText.trim()) {
throw new Error("No text transcribed - Smallest AI response was empty");
}

timings.transcriptionProcessingDurationMs = Math.round(performance.now() - apiCallStart);
const reasoningStart = performance.now();
const text = await this.processTranscription(rawText, "smallest");
timings.reasoningProcessingDurationMs = Math.round(performance.now() - reasoningStart);

return { success: true, text, rawText, source: "smallest", timings };
}

logger.debug(
"Making transcription API request",
{
Expand Down Expand Up @@ -1713,6 +1761,7 @@ registerProcessor("pcm-streaming-processor", PCMStreamingProcessor);
// Return provider-appropriate default
if (provider === "groq") return "whisper-large-v3-turbo";
if (provider === "mistral") return "voxtral-mini-latest";
if (provider === "smallest") return "pulse";
return "gpt-4o-mini-transcribe";
} catch (error) {
return "gpt-4o-mini-transcribe";
Expand Down Expand Up @@ -1797,6 +1846,10 @@ registerProcessor("pcm-streaming-processor", PCMStreamingProcessor);
return endpoint;
};

if (currentProvider === "smallest") {
return cacheResult("https://api.smallest.ai/waves/v1/pulse/get_text");
}

if (!normalizedBase) {
logger.debug(
"STT endpoint: using default (normalization failed)",
Expand Down
9 changes: 9 additions & 0 deletions src/helpers/environment.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const PERSISTED_KEYS = [
"MISTRAL_API_KEY",
"ASSEMBLYAI_API_KEY",
"DEEPGRAM_API_KEY",
"SMALLEST_AI_API_KEY",
"CUSTOM_TRANSCRIPTION_API_KEY",
"CUSTOM_REASONING_API_KEY",
"LOCAL_TRANSCRIPTION_PROVIDER",
Expand Down Expand Up @@ -142,6 +143,14 @@ class EnvironmentManager {
return this._saveKey("DEEPGRAM_API_KEY", key);
}

getSmallestAiKey() {
return this._getKey("SMALLEST_AI_API_KEY");
}

saveSmallestAiKey(key) {
return this._saveKey("SMALLEST_AI_API_KEY", key);
}

getCustomTranscriptionKey() {
return this._getKey("CUSTOM_TRANSCRIPTION_API_KEY");
}
Expand Down
70 changes: 53 additions & 17 deletions src/helpers/ipcHandlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ function parseAttendees(raw) {
}

const MISTRAL_TRANSCRIPTION_URL = "https://api.mistral.ai/v1/audio/transcriptions";
const SMALLEST_AI_TRANSCRIPTION_URL = "https://api.smallest.ai/waves/v1/pulse/get_text";

// Debounce delay: wait for user to stop typing before processing corrections
const AUTO_LEARN_DEBOUNCE_MS = 1500;
Expand Down Expand Up @@ -471,6 +472,7 @@ class IPCHandlers {
}
if (provider === "groq") return "whisper-large-v3-turbo";
if (provider === "mistral") return "voxtral-mini-latest";
if (provider === "smallest") return "pulse";
return "gpt-4o-mini-transcribe";
}

Expand Down Expand Up @@ -2425,6 +2427,14 @@ class IPCHandlers {
return this.environmentManager.saveMistralKey(key);
});

ipcMain.handle("get-smallest-ai-key", async () => {
return this.environmentManager.getSmallestAiKey();
});

ipcMain.handle("save-smallest-ai-key", async (event, key) => {
return this.environmentManager.saveSmallestAiKey(key);
});

ipcMain.handle(
"proxy-mistral-transcription",
async (event, { audioBuffer, model, language, contextBias }) => {
Expand Down Expand Up @@ -3484,6 +3494,9 @@ class IPCHandlers {
} else if (provider === "mistral") {
apiKey = this.environmentManager.getMistralKey();
endpoint = MISTRAL_TRANSCRIPTION_URL;
} else if (provider === "smallest") {
apiKey = this.environmentManager.getSmallestAiKey();
endpoint = SMALLEST_AI_TRANSCRIPTION_URL;
} else if (provider === "custom") {
apiKey = this.environmentManager.getCustomTranscriptionKey();
const base = (settings?.cloudTranscriptionBaseUrl || "").trim();
Expand All @@ -3500,24 +3513,47 @@ class IPCHandlers {
throw new Error(`${provider} API key not configured`);
}

const formData = new FormData();
formData.append("file", new Blob([buffer], { type: "audio/webm" }), "audio.webm");
formData.append("model", model);
const headers = {};
if (provider === "mistral") {
headers["x-api-key"] = apiKey;
} else if (apiKey) {
headers.Authorization = `Bearer ${apiKey}`;
}
if (provider === "smallest") {
const language = settings?.language || "en";
const params = new URLSearchParams({ language });
const smallestHeaders = {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/octet-stream",
"X-Source": "openwhispr",
};
const smallestResponse = await fetch(`${endpoint}?${params}`, {
method: "POST",
headers: smallestHeaders,
body: buffer,
});
if (!smallestResponse.ok) {
const errorText = await smallestResponse.text();
throw new Error(`Smallest AI API Error: ${smallestResponse.status} ${errorText}`);
}
const smallestData = await smallestResponse.json();
if (smallestData?.transcription) {
result = { text: smallestData.transcription, source: provider, model };
}
} else {
const formData = new FormData();
formData.append("file", new Blob([buffer], { type: "audio/webm" }), "audio.webm");
formData.append("model", model);
const headers = {};
if (provider === "mistral") {
headers["x-api-key"] = apiKey;
} else if (apiKey) {
headers.Authorization = `Bearer ${apiKey}`;
}

const response = await fetch(endpoint, { method: "POST", headers, body: formData });
if (!response.ok) {
const errorText = await response.text();
throw new Error(`${provider} API Error: ${response.status} ${errorText}`);
}
const data = await response.json();
if (data?.text) {
result = { text: data.text, source: provider, model };
const response = await fetch(endpoint, { method: "POST", headers, body: formData });
if (!response.ok) {
const errorText = await response.text();
throw new Error(`${provider} API Error: ${response.status} ${errorText}`);
}
const data = await response.json();
if (data?.text) {
result = { text: data.text, source: provider, model };
}
}
}

Expand Down
3 changes: 3 additions & 0 deletions src/hooks/useSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export interface ApiKeySettings {
geminiApiKey: string;
groqApiKey: string;
mistralApiKey: string;
smallestAiApiKey: string;
customTranscriptionApiKey: string;
customReasoningApiKey: string;
}
Expand Down Expand Up @@ -205,6 +206,7 @@ function useSettingsInternal() {
geminiApiKey: store.geminiApiKey,
groqApiKey: store.groqApiKey,
mistralApiKey: store.mistralApiKey,
smallestAiApiKey: store.smallestAiApiKey,
dictationKey: store.dictationKey,
meetingKey: store.meetingKey,
theme: store.theme,
Expand Down Expand Up @@ -238,6 +240,7 @@ function useSettingsInternal() {
setGeminiApiKey: store.setGeminiApiKey,
setGroqApiKey: store.setGroqApiKey,
setMistralApiKey: store.setMistralApiKey,
setSmallestAiApiKey: store.setSmallestAiApiKey,
customTranscriptionApiKey: store.customTranscriptionApiKey,
setCustomTranscriptionApiKey: store.setCustomTranscriptionApiKey,
customReasoningApiKey: store.customReasoningApiKey,
Expand Down
Loading