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
Empty file added apps/demo-mcp/.data/reddit.db
Empty file.
34 changes: 31 additions & 3 deletions apps/whispering/src/lib/services/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,36 @@ async function transcribe(
}
```

## 🧩 Utility Wrappers

withRetry — Retry + Timeout for External Calls
withRetry is a shared utility for wrapping async operations that may fail transiently (e.g., network hiccups, rate limits). It adds retry logic and timeout protection to external API calls, ensuring contributor-safe resilience across services.

Usage:
import { withRetry } from '$lib/services/completion/utils/withRetry';

const result = await withRetry(() => apiCall(), {
retries: 2,
delayMs: 1000,
timeoutMs: 8000,
});

Options:

- retries: number of retry attempts (default: 2)
- delayMs: delay between retries in milliseconds (default: 1000)
- timeoutMs: max time before aborting the call (default: 8000)

Used in:

- openai.ts
- groq.ts
- deepgram.ts
- elevenlabs.ts
- mistral.ts

This utility ensures consistent retry behavior across all transcription services without duplicating logic. It’s designed to be pure, testable, and platform-agnostic.

## Service-Specific Error Types

Each service defines its own `TaggedError` type to represent domain-specific failures. These error types are part of the service's public API and contain all the context needed to understand what went wrong:
Expand Down Expand Up @@ -222,9 +252,7 @@ export function createManualRecorderService() {
startRecording: async (
recordingSettings,
{ sendStatus },
): Promise<
Result<DeviceAcquisitionOutcome, RecorderServiceError>
> => {
): Promise<Result<DeviceAcquisitionOutcome, RecorderServiceError>> => {
if (activeRecording) {
return Err({
name: 'RecorderServiceError',
Expand Down
26 changes: 26 additions & 0 deletions apps/whispering/src/lib/services/completion/utils/withRetry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
export async function withRetry<T>(
fn: () => Promise<T>,
options: {
retries?: number;
delayMs?: number;
timeoutMs?: number;
},
): Promise<T> {
const { retries = 3, delayMs = 500, timeoutMs = 10000 } = options;

for (let attempt = 0; attempt <= retries; attempt++) {
try {
const result = await Promise.race([
fn(),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), timeoutMs),
),
]);
return result;
} catch (_error) {
if (attempt === retries) throw _error;
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
throw new Error('Retry failed');
}
28 changes: 18 additions & 10 deletions apps/whispering/src/lib/services/transcription/cloud/deepgram.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Ok, type Result } from 'wellcrafted/result';
import { z } from 'zod';
import { WhisperingErr, type WhisperingError } from '$lib/result';
import { withRetry } from '$lib/services/completion/utils/withRetry';
import type { HttpService } from '$lib/services/http';
import { HttpServiceLive } from '$lib/services/http';
import type { Settings } from '$lib/settings';
Expand Down Expand Up @@ -118,16 +119,23 @@ export function createDeepgramTranscriptionService({
}

// Send raw audio data directly as recommended by Deepgram docs
const { data: deepgramResponse, error: postError } =
await HttpService.post({
url: `https://api.deepgram.com/v1/listen?${params.toString()}`,
body: audioBlob, // Send raw audio blob directly
headers: {
Authorization: `Token ${options.apiKey}`,
'Content-Type': audioBlob.type || 'audio/*', // Use the blob's mime type or fallback to audio/*
},
schema: deepgramResponseSchema,
});
const { data: deepgramResponse, error: postError } = await withRetry(
() =>
HttpService.post({
url: `https://api.deepgram.com/v1/listen?${params.toString()}`,
body: audioBlob,
headers: {
Authorization: `Token ${options.apiKey}`,
'Content-Type': audioBlob.type || 'audio/*',
},
schema: deepgramResponseSchema,
}),
{
retries: 2,
delayMs: 1000,
timeoutMs: 8000,
},
);

if (postError) {
switch (postError.name) {
Expand Down
30 changes: 19 additions & 11 deletions apps/whispering/src/lib/services/transcription/cloud/elevenlabs.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ElevenLabsClient } from 'elevenlabs';
import { Ok, type Result } from 'wellcrafted/result';
import { WhisperingErr, type WhisperingError } from '$lib/result';
import { withRetry } from '$lib/services/completion/utils/withRetry';
import type { Settings } from '$lib/settings';

export const ELEVENLABS_TRANSCRIPTION_MODELS = [
Expand Down Expand Up @@ -62,17 +63,24 @@ export function createElevenLabsTranscriptionService() {
}

// Use the client's speechToText functionality
const transcription = await client.speechToText.convert({
file: audioBlob,
model_id: options.modelName,
// Map outputLanguage if not set to 'auto'
language_code:
options.outputLanguage !== 'auto'
? options.outputLanguage
: undefined,
tag_audio_events: false,
diarize: true,
});
const transcription = await withRetry(
() =>
client.speechToText.convert({
file: audioBlob,
model_id: options.modelName,
language_code:
options.outputLanguage !== 'auto'
? options.outputLanguage
: undefined,
tag_audio_events: false,
diarize: true,
}),
{
retries: 2,
delayMs: 1000,
timeoutMs: 8000,
},
);

// Return the transcribed text
return Ok(transcription.text.trim());
Expand Down
39 changes: 24 additions & 15 deletions apps/whispering/src/lib/services/transcription/cloud/groq.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Groq from 'groq-sdk';
import { Err, Ok, type Result, tryAsync, trySync } from 'wellcrafted/result';
import { WhisperingErr, type WhisperingError } from '$lib/result';
import { getExtensionFromAudioBlob } from '$lib/services/_utils';
import { withRetry } from '$lib/services/completion/utils/withRetry';
import type { Settings } from '$lib/settings';

export const GROQ_MODELS = [
Expand Down Expand Up @@ -95,21 +96,29 @@ export function createGroqTranscriptionService() {
// Make the transcription request
const { data: transcription, error: groqApiError } = await tryAsync({
try: () =>
new Groq({
apiKey: options.apiKey,
dangerouslyAllowBrowser: true,
}).audio.transcriptions.create({
file,
model: options.modelName,
language:
options.outputLanguage === 'auto'
? undefined
: options.outputLanguage,
prompt: options.prompt ? options.prompt : undefined,
temperature: options.temperature
? Number.parseFloat(options.temperature)
: undefined,
}),
withRetry(
() =>
new Groq({
apiKey: options.apiKey,
dangerouslyAllowBrowser: true,
}).audio.transcriptions.create({
file,
model: options.modelName,
language:
options.outputLanguage === 'auto'
? undefined
: options.outputLanguage,
prompt: options.prompt || undefined,
temperature: options.temperature
? Number.parseFloat(options.temperature)
: undefined,
}),
{
retries: 2,
delayMs: 1000,
timeoutMs: 8000,
},
),
catch: (error) => {
// Check if it's NOT a Groq API error
if (!(error instanceof Groq.APIError)) {
Expand Down
35 changes: 22 additions & 13 deletions apps/whispering/src/lib/services/transcription/cloud/mistral.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Mistral } from '@mistralai/mistralai';
import { Err, Ok, type Result, tryAsync, trySync } from 'wellcrafted/result';
import { WhisperingErr, type WhisperingError } from '$lib/result';
import { getExtensionFromAudioBlob } from '$lib/services/_utils';
import { withRetry } from '$lib/services/completion/utils/withRetry';
import type { Settings } from '$lib/settings';
export const MISTRAL_TRANSCRIPTION_MODELS = [
{
Expand Down Expand Up @@ -78,19 +79,27 @@ export function createMistralTranscriptionService() {
// Make the transcription request
const { data: transcription, error: mistralApiError } = await tryAsync({
try: () =>
new Mistral({
apiKey: options.apiKey,
}).audio.transcriptions.complete({
file,
model: options.modelName,
language:
options.outputLanguage !== 'auto'
? options.outputLanguage
: undefined,
temperature: options.temperature
? Number.parseFloat(options.temperature)
: undefined,
}),
withRetry(
() =>
new Mistral({
apiKey: options.apiKey,
}).audio.transcriptions.complete({
file,
model: options.modelName,
language:
options.outputLanguage !== 'auto'
? options.outputLanguage
: undefined,
temperature: options.temperature
? Number.parseFloat(options.temperature)
: undefined,
}),
{
retries: 2,
delayMs: 1000,
timeoutMs: 8000,
}
),
catch: (error) => {
// Return the error directly for processing
return Err(error);
Expand Down
41 changes: 25 additions & 16 deletions apps/whispering/src/lib/services/transcription/cloud/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import OpenAI from 'openai';
import { Err, Ok, type Result, tryAsync, trySync } from 'wellcrafted/result';
import { WhisperingErr, type WhisperingError } from '$lib/result';
import { getExtensionFromAudioBlob } from '$lib/services/_utils';
import { withRetry } from '$lib/services/completion/utils/withRetry';
import type { Settings } from '$lib/settings';

export const OPENAI_TRANSCRIPTION_MODELS = [
Expand Down Expand Up @@ -89,7 +90,7 @@ export function createOpenaiTranscriptionService() {
`recording.${getExtensionFromAudioBlob(audioBlob)}`,
{ type: audioBlob.type },
),
catch: (error) =>
catch: (_error) =>
WhisperingErr({
title: '📁 File Creation Failed',
description:
Expand All @@ -102,21 +103,29 @@ export function createOpenaiTranscriptionService() {
// Call OpenAI API
const { data: transcription, error: openaiApiError } = await tryAsync({
try: () =>
new OpenAI({
apiKey: options.apiKey,
dangerouslyAllowBrowser: true,
}).audio.transcriptions.create({
file,
model: options.modelName,
language:
options.outputLanguage !== 'auto'
? options.outputLanguage
: undefined,
prompt: options.prompt || undefined,
temperature: options.temperature
? Number.parseFloat(options.temperature)
: undefined,
}),
withRetry(
() =>
new OpenAI({
apiKey: options.apiKey,
dangerouslyAllowBrowser: true,
}).audio.transcriptions.create({
file,
model: options.modelName,
language:
options.outputLanguage !== 'auto'
? options.outputLanguage
: undefined,
prompt: options.prompt || undefined,
temperature: options.temperature
? Number.parseFloat(options.temperature)
: undefined,
}),
{
retries: 2,
delayMs: 1000,
timeoutMs: 8000,
},
),
catch: (error) => {
// Check if it's NOT an OpenAI API error
if (!(error instanceof OpenAI.APIError)) {
Expand Down
15 changes: 5 additions & 10 deletions apps/whispering/svelte.config.js
Original file line number Diff line number Diff line change
@@ -1,29 +1,24 @@
// Tauri doesn't have a Node.js server to do proper SSR
// so we will use adapter-static to prerender the app (SSG)
// This works for both Tauri and Cloudflare Workers + Assets
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
import path from 'node:path';
import staticAdapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';

/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: staticAdapter({
fallback: 'index.html', // SPA fallback for dynamic routes
fallback: 'index.html',
}),
alias: {
$lib: path.resolve('./src/lib'),
},
},

// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(),

vitePlugin: {
inspector: {
holdMode: true,
showToggleButton: 'always',
// Using 'bottom-left' as base position, but CSS overrides in
// src/routes/+layout.svelte move it to bottom-center to avoid
// conflicts with devtools (bottom-left) and toasts (bottom-right)
toggleButtonPos: 'bottom-left',
toggleKeyCombo: 'meta-shift',
},
Expand Down
6 changes: 2 additions & 4 deletions bun.lock
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "epicenter",
"dependencies": {
"wellcrafted": "catalog:",
},
"devDependencies": {
"@biomejs/biome": "^2.3.1",
"@eslint/compat": "^1.4.0",
Expand Down Expand Up @@ -148,7 +146,7 @@
},
"apps/whispering": {
"name": "@repo/whispering",
"version": "7.7.0",
"version": "7.7.1",
"dependencies": {
"@anthropic-ai/sdk": "^0.55.0",
"@aptabase/tauri": "^0.4.1",
Expand Down