Skip to content

Commit 93a9c2a

Browse files
committed
Add voice input (Whisper STT) and read-aloud (browser TTS)
Mic button records and transcribes speech via OpenAI's Whisper API into the composer; assistant replies can be read aloud per-message or automatically, using the OS's own TTS voices (no API key needed for read-aloud). Both are interruptible mid-action.
1 parent 4ee8ae6 commit 93a9c2a

10 files changed

Lines changed: 324 additions & 3 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,12 @@ Beyond chat, Modelforge includes an **agentic mode** — the model can read/writ
5656
- Data export/import, and one-click "copy diagnostic info" for bug reports.
5757
- **Updates** — packaged builds check GitHub Releases for new versions automatically on launch, plus a manual "Check for updates" button in Settings (also available from the app menu).
5858

59+
**Voice**
60+
- **Voice input** — record a question with the mic button; it's transcribed via OpenAI's Whisper API and dropped into the composer (requires an OpenAI API key in Settings, even when chatting with a local Ollama model).
61+
- **Read aloud** — any assistant reply can be played back through your OS's own text-to-speech voices, with a per-message speaker button, an optional "auto-read every response" toggle, and a voice picker with a test button in Settings → Voice. Works fully offline, no API key needed.
62+
- Both are start/stop/cancel controllable mid-action — stop a reply from being read, or cancel a recording before it's sent for transcription.
63+
- Not included: fully real-time, bidirectional voice conversation (speaking over the model and having it react instantly, à la OpenAI's Realtime API). That's a different streaming architecture and hasn't been built.
64+
5965
## Screenshots
6066

6167
<details>

app/src/main.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,27 @@ function registerIpcHandlers(): void {
275275
secretsStore.setSecret(requireString(key, "secret key"), value ?? "")
276276
);
277277

278+
ipcMain.handle(
279+
"audio:transcribe",
280+
async (_event: IpcMainInvokeEvent, { audioBase64, mimeType }: { audioBase64: string; mimeType: string }) => {
281+
requireString(audioBase64, "audio data");
282+
const apiKey = secretsStore.getSecret("openai_api_key");
283+
if (!apiKey) {
284+
return { error: "Voice input needs an OpenAI API key — add one in Settings to use it." };
285+
}
286+
try {
287+
const buffer = Buffer.from(audioBase64, "base64");
288+
const ext = mimeType.includes("webm") ? "webm" : mimeType.includes("ogg") ? "ogg" : "wav";
289+
const text = await openaiProvider.transcribeAudio(apiKey, buffer, `audio.${ext}`);
290+
return { text };
291+
} catch (err) {
292+
const error = err as Error;
293+
logger.error(`Audio transcription failed: ${error.message}`);
294+
return { error: error.message };
295+
}
296+
}
297+
);
298+
278299
ipcMain.handle("app:setBusy", (_event: IpcMainInvokeEvent, busy: boolean) => {
279300
isBusy = busy;
280301
});

app/src/preload.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,11 @@ contextBridge.exposeInMainWorld("api", {
8585
set: (key: string, value: string) => ipcRenderer.invoke("secrets:set", { key, value }),
8686
},
8787

88+
audio: {
89+
transcribe: (audioBase64: string, mimeType: string): Promise<{ text?: string; error?: string }> =>
90+
ipcRenderer.invoke("audio:transcribe", { audioBase64, mimeType }),
91+
},
92+
8893
app: {
8994
setBusy: (busy: boolean) => ipcRenderer.invoke("app:setBusy", busy),
9095
getVersion: () => ipcRenderer.invoke("app:getVersion"),

app/src/providers/openai.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,33 @@ import { streamSSE } from "./sse";
22
import { describeHttpError, describeNetworkError } from "./errors";
33
import type { ChatFn, ToolDefinition } from "./types";
44

5+
// Speech-to-text for voice input. Whisper's API is a separate REST endpoint
6+
// from chat completions (multipart file upload, not JSON), so it doesn't fit
7+
// the streaming ChatFn shape above.
8+
export async function transcribeAudio(apiKey: string, audioBuffer: Buffer, filename: string): Promise<string> {
9+
const form = new FormData();
10+
form.append("file", new Blob([new Uint8Array(audioBuffer)]), filename);
11+
form.append("model", "whisper-1");
12+
13+
let res: Response;
14+
try {
15+
res = await fetch("https://api.openai.com/v1/audio/transcriptions", {
16+
method: "POST",
17+
headers: { Authorization: `Bearer ${apiKey}` },
18+
body: form,
19+
});
20+
} catch (err) {
21+
throw describeNetworkError("OpenAI", err);
22+
}
23+
24+
if (!res.ok) {
25+
throw new Error(await describeHttpError(res, "OpenAI"));
26+
}
27+
28+
const data = (await res.json()) as { text?: string };
29+
return data.text ?? "";
30+
}
31+
532
function toOpenAiTools(tools: ToolDefinition[]): unknown[] {
633
return tools.map((t) => ({
734
type: "function",

app/src/settings-store.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ export interface AppSettings {
3838
promptPresets: PromptPreset[];
3939
theme: "light" | "dark" | "system";
4040
language: "en" | "tr";
41+
// Text-to-speech: which browser/OS voice to use (voiceURI from
42+
// speechSynthesis.getVoices(), chosen client-side) and whether assistant
43+
// responses should be read aloud automatically as they finish.
44+
ttsVoiceURI?: string;
45+
ttsAutoRead?: boolean;
4146
}
4247

4348
const DEFAULTS: AppSettings = {

frontend/src/lib/translations.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,17 @@ export interface Dictionary {
8080
noPreviousVersions: string;
8181
savePreset: string;
8282
promptLibraryVariablesHint: string;
83+
startRecording: string;
84+
stopRecording: string;
85+
cancelRecording: string;
86+
transcribing: string;
87+
ttsSection: string;
88+
ttsAutoRead: string;
89+
ttsVoice: string;
90+
ttsVoiceDefault: string;
91+
ttsVoiceTest: string;
92+
enabled: string;
93+
disabled: string;
8394
resetToDefault: string;
8495
usingCustomPrompt: string;
8596
dataManagement: string;
@@ -198,6 +209,17 @@ export const en: Dictionary = {
198209
noPreviousVersions: "No previous versions yet.",
199210
savePreset: "Save",
200211
promptLibraryVariablesHint: "Add {{variables}} to a prompt (e.g. {{topic}}) and you'll be asked to fill them in each time you apply it. Edits keep version history so you can undo a change.",
212+
startRecording: "Start voice input",
213+
stopRecording: "Stop and transcribe",
214+
cancelRecording: "Cancel recording",
215+
transcribing: "Transcribing...",
216+
ttsSection: "Voice",
217+
ttsAutoRead: "Automatically read responses aloud",
218+
ttsVoice: "Voice",
219+
ttsVoiceDefault: "System default",
220+
ttsVoiceTest: "Test",
221+
enabled: "Enabled",
222+
disabled: "Disabled",
201223
resetToDefault: "Reset to default",
202224
usingCustomPrompt: "Custom prompt for this chat",
203225
dataManagement: "Data management",
@@ -324,6 +346,17 @@ export const tr: Dictionary = {
324346
noPreviousVersions: "Henüz önceki bir sürüm yok.",
325347
savePreset: "Kaydet",
326348
promptLibraryVariablesHint: "Bir isteme {{değişkenler}} ekleyin (ör. {{konu}}) — her uyguladığınızda bunları doldurmanız istenir. Düzenlemeler sürüm geçmişini korur, böylece bir değişikliği geri alabilirsiniz.",
349+
startRecording: "Sesli girişi başlat",
350+
stopRecording: "Durdur ve yazıya dök",
351+
cancelRecording: "Kaydı iptal et",
352+
transcribing: "Yazıya dökülüyor...",
353+
ttsSection: "Ses",
354+
ttsAutoRead: "Yanıtları otomatik olarak sesli oku",
355+
ttsVoice: "Ses",
356+
ttsVoiceDefault: "Sistem varsayılanı",
357+
ttsVoiceTest: "Test et",
358+
enabled: "Etkin",
359+
disabled: "Kapalı",
327360
resetToDefault: "Varsayılana dön",
328361
usingCustomPrompt: "Bu sohbet için özel istem",
329362
dataManagement: "Veri yönetimi",

frontend/src/lib/tts.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Text-to-speech via the browser's native SpeechSynthesis API — works
2+
// offline using whatever voices the OS provides (no API key, no network
3+
// call), consistent with this app's local-first approach. Electron's
4+
// Chromium supports this directly since it doesn't depend on the Google
5+
// cloud speech services that plain SpeechRecognition (STT) would need.
6+
export function speakText(text: string, voiceURI: string | undefined, onEnd: () => void): void {
7+
window.speechSynthesis.cancel();
8+
const utterance = new SpeechSynthesisUtterance(text);
9+
if (voiceURI) {
10+
const voice = window.speechSynthesis.getVoices().find((v) => v.voiceURI === voiceURI);
11+
if (voice) utterance.voice = voice;
12+
}
13+
utterance.onend = onEnd;
14+
utterance.onerror = onEnd;
15+
window.speechSynthesis.speak(utterance);
16+
}
17+
18+
export function stopSpeaking(): void {
19+
window.speechSynthesis.cancel();
20+
}

0 commit comments

Comments
 (0)