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
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ const SettingsDashboard: React.FC<SettingsDashboardProps> = ({

const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null);
const [showVoiceCloneModal, setShowVoiceCloneModal] = useState<{
provider: "elevenlabs" | "hume";
provider: "elevenlabs" | "hume" | "60db";
title: string;
voiceInputLabel: string;
voiceInputPlaceholder: string;
Expand Down Expand Up @@ -412,6 +412,16 @@ const SettingsDashboard: React.FC<SettingsDashboardProps> = ({
<Plus className="w-4 h-4 flex-shrink-0" />
Eleven Labs Agent
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowVoiceCloneModal({ provider: "60db", title: "60db Character", voiceInputLabel: "60db Voice ID", voiceInputPlaceholder: "fbb75ed2-975a-40c7-9e06-38e30524a9a1", voiceDescription: "Voice UUID from https://api.60db.ai/myvoices or /default-voices. The agentId field maps to 60db's voice_id; the full STT+LLM+TTS pipeline is server-managed." })}
className="flex items-center gap-2"
>
<Plus className="w-4 h-4 flex-shrink-0" />
60db Voice
</Button>
</div>
</div>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ interface VoiceCloneModalProps {
onSuccess?: () => void;
selectedUser: IUser;
voiceCloneModalProps: {
provider: "elevenlabs" | "hume";
provider: "elevenlabs" | "hume" | "60db";
title: string;
voiceInputLabel: string;
voiceInputPlaceholder: string;
Expand Down
100 changes: 100 additions & 0 deletions server/cloudflare/models/sixtydb.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* 60db.ai backend for the Cloudflare worker / Durable Object.
*
* Cloudflare deployments use Workers AI bindings for STT/LLM/TTS by default.
* The cleanest way to slot 60db in without rebuilding the whole DO pipeline
* is to expose a one-shot REST synthesizer that the existing TTS module can
* delegate to when TTS_BACKEND=60db.
*
* If you also want 60db STT/LLM in Cloudflare, the full bidirectional
* implementation lives in server/deno/models/sixtydb.ts and is straight-
* forward to port (Cloudflare Workers do support outbound WebSockets via
* the WebSocket API in Durable Objects).
*
* Docs: https://docs.60db.ai/api-reference/tts/text-to-speech
*/

import type { Env } from "../src/types";

const SYNTHESIZE_URL = "https://api.60db.ai/tts-synthesize";
const DEFAULT_VOICE_ID = "fbb75ed2-975a-40c7-9e06-38e30524a9a1"; // Zara

const AUDIO_SAMPLE_RATE = 24_000;

interface SynthesizeResponse {
success?: boolean;
message?: string;
audio_base64?: string;
sample_rate?: number;
duration_seconds?: number;
encoding?: string;
output_format?: string;
}

function decodeBase64(b64: string): Uint8Array {
// Cloudflare Workers expose atob, so we can decode via that path. For
// small audio payloads this is plenty fast; for larger ones consider
// streaming through Response(body) directly.
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}

export async function synthesizeSpeechWith60db(
env: Env,
text: string,
): Promise<Response> {
if (!env.SIXTYDB_API_KEY?.trim()) {
throw new Error("SIXTYDB_API_KEY is missing");
}

const voiceId = env.SIXTYDB_VOICE_ID?.trim() || DEFAULT_VOICE_ID;

const upstream = await fetch(SYNTHESIZE_URL, {
method: "POST",
headers: {
"Authorization": `Bearer ${env.SIXTYDB_API_KEY}`,
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify({
text,
voice_id: voiceId,
enhance: true,
speed: 1.0,
stability: 50,
similarity: 75,
// PCM16 mono — matches the format the DO's Opus packetizer expects.
// Falls back to mp3 if 60db rejects (some accounts only ship mp3/wav).
output_format: "wav",
}),
});

if (!upstream.ok) {
const text = await upstream.text().catch(() => "");
throw new Error(`60db /tts-synthesize HTTP ${upstream.status}: ${text.slice(0, 200)}`);
}

const body = (await upstream.json()) as SynthesizeResponse;
if (!body.audio_base64) {
throw new Error(`60db returned no audio: ${body.message ?? "unknown"}`);
}

const audio = decodeBase64(body.audio_base64);

// Hand BodyInit an ArrayBuffer (not a typed-array view) to dodge the
// Uint8Array<ArrayBufferLike> generic incompatibility in TS 5.7+ libs.
const arrayBuffer = audio.buffer.slice(
audio.byteOffset,
audio.byteOffset + audio.byteLength,
) as ArrayBuffer;

return new Response(arrayBuffer, {
headers: {
"Content-Type": "audio/wav",
"X-Sample-Rate": String(body.sample_rate ?? AUDIO_SAMPLE_RATE),
"X-Encoding": body.encoding ?? "linear16",
},
});
}
5 changes: 5 additions & 0 deletions server/cloudflare/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,10 @@ export interface Env {
ELATO_OPENAI_MODEL?: string;
ELATO_OPENAI_SYSTEM_PROMPT?: string;
ELATO_OPENAI_FIRST_MESSAGE?: string;
// 60db.ai integration — set TTS_BACKEND="60db" to swap synthesizeSpeech()
// from Workers AI / Deepgram Aura to 60db's REST /tts-synthesize.
TTS_BACKEND?: "workers-ai" | "60db";
SIXTYDB_API_KEY?: string;
SIXTYDB_VOICE_ID?: string;
ElatoVoiceSession: DurableObjectNamespace;
}
4 changes: 4 additions & 0 deletions server/deno/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { connectToGemini } from "./models/gemini.ts";
import { connectToElevenLabs } from "./models/elevenlabs.ts";
import { connectToHume } from "./models/hume.ts";
import { connectToGrok } from "./models/grok.ts";
import { connectTo60db } from "./models/sixtydb.ts";

const server = createServer();

Expand Down Expand Up @@ -97,6 +98,9 @@ wss.on("connection", async (ws: WSWebSocket, payload: IPayload) => {
case "hume":
await connectToHume(providerArgs);
break;
case "60db":
await connectTo60db(providerArgs);
break;
default:
throw new Error(`Unknown provider: ${provider}`);
}
Expand Down
Loading