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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,24 @@ The visual style is fully customisable: expand **Advanced — Customize System P
| Provider | Capability |
|---|---|
| **ElevenLabs** | Scene-by-scene narration voiceover + SFX generation |
| **60db** | Cloud TTS peer of ElevenLabs (this fork — `/api/sixtydb/voices` + `/tts` + `/tts/scene`; no SFX surface) |
| **Local TTS** | Bring your own (QWEN TTS, Kokoro, etc.) — zero cost |

#### 60db provider (alongside ElevenLabs)

This fork adds `backend/routes/sixtydb.js` as a parallel route module. It mirrors the ElevenLabs route shape so the frontend can swap providers per request by changing endpoint URLs only:

| ElevenLabs | 60db equivalent |
|---|---|
| `GET /api/elevenlabs/voices` | `GET /api/sixtydb/voices` |
| `POST /api/elevenlabs/tts` | `POST /api/sixtydb/tts` |
| `POST /api/elevenlabs/tts/scene` | `POST /api/sixtydb/tts/scene` |
| `POST /api/elevenlabs/sfx` | _no equivalent — 60db has no SFX endpoint_ |

Both providers return `data:audio/mp3;base64,…` URLs so `AudioGeneration.jsx`'s `<audio>` tags work unchanged. The bracketed-cue convention (`[whisper]`, `[pause 3s]`) is honored identically by `/tts/scene` on both providers.

Set `SIXTYDB_API_KEY` (env or via the in-app Settings panel) to enable. Reference: [docs.60db.ai](https://docs.60db.ai).

---

## Real-World Cost
Expand Down
35 changes: 32 additions & 3 deletions backend/routes/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,20 @@ router.get('/', (req, res) => {
replicate: !!(keys.replicate && keys.replicate.trim()),
gemini: !!(keys.gemini && keys.gemini.trim()),
elevenlabs: !!(keys.elevenlabs && keys.elevenlabs.trim()),
sixtydb: !!(keys.sixtydb && keys.sixtydb.trim()),
});
});

router.post('/', (req, res) => {
const { falKey, replicateKey, geminiKey, elevenlabsKey } = req.body;
const { falKey, replicateKey, geminiKey, elevenlabsKey, sixtydbKey } = req.body;
const keys = req.app.get('apiKeys');
const saveKeysToEnv = req.app.get('saveKeysToEnv');

if (falKey !== undefined) keys.fal = falKey.trim();
if (replicateKey !== undefined) keys.replicate = replicateKey.trim();
if (geminiKey !== undefined) keys.gemini = geminiKey.trim();
if (elevenlabsKey !== undefined) keys.elevenlabs = elevenlabsKey.trim();
if (sixtydbKey !== undefined) keys.sixtydb = sixtydbKey.trim();

saveKeysToEnv();
res.json({ success: true });
Expand Down Expand Up @@ -67,7 +69,34 @@ router.post('/validate', async (req, res) => {
await client.voices.getAll();
return res.json({ valid: true });
}


case 'sixtydb': {
// 60db has no /voices listing surface yet, so we ping the smallest
// billable surface (a 1-char synthesis) to confirm the bearer is
// accepted. This costs ~$0 per request given the tiny payload.
const apiBase = (process.env.SIXTYDB_API_BASE || 'https://api.60db.ai').replace(/\/$/, '');
const response = await fetch(`${apiBase}/tts-synthesize`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${key.trim()}`,
},
body: JSON.stringify({
text: '.',
voice_id: 'fbb75ed2-975a-40c7-9e06-38e30524a9a1',
output_format: 'mp3',
}),
});
if (response.status === 401 || response.status === 403) {
return res.json({ valid: false, error: 'Invalid API key' });
}
if (!response.ok) {
const body = await response.text().catch(() => '');
return res.json({ valid: false, error: `60db ${response.status}: ${body.slice(0, 200)}` });
}
return res.json({ valid: true });
}

default:
return res.json({ valid: false, error: 'Unknown provider' });
}
Expand Down
145 changes: 145 additions & 0 deletions backend/routes/sixtydb.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import express from 'express';

// 60db cloud — peer of ElevenLabs for narration TTS.
//
// Mirrors backend/routes/elevenlabs.js's contract so the frontend can
// swap providers by changing only the endpoint URL:
// GET /api/sixtydb/voices → same shape as /api/elevenlabs/voices
// POST /api/sixtydb/tts → same shape as /api/elevenlabs/tts
// POST /api/sixtydb/tts/scene → same shape as /api/elevenlabs/tts/scene
//
// 60db has no native SFX surface, so there is no /sfx endpoint here. If
// you need sound effects, keep using /api/elevenlabs/sfx for that one
// concern while routing voice through 60db.
//
// Audio is returned as `data:audio/mp3;base64,...` URLs identical to the
// ElevenLabs route, so the AudioGeneration page's <audio> tags work
// unchanged regardless of which provider produced the bytes.
//
// Reference: https://docs.60db.ai

const router = express.Router();

const DEFAULT_API_BASE = 'https://api.60db.ai';
const DEFAULT_VOICE_ID = 'fbb75ed2-975a-40c7-9e06-38e30524a9a1'; // docs example
const DEFAULT_MODEL_ID = '60db-default';

const getConfig = (req) => {
const keys = req.app.get('apiKeys');
if (!keys.sixtydb) {
throw new Error('60db API key not configured');
}
return {
apiKey: keys.sixtydb,
apiBase: (process.env.SIXTYDB_API_BASE || DEFAULT_API_BASE).replace(/\/$/, ''),
};
};

// One-shot synthesis: POST /tts-synthesize → { success, audio_base64, ... }.
const synthesize = async (text, voiceId, apiBase, apiKey) => {
const response = await fetch(`${apiBase}/tts-synthesize`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
text,
voice_id: voiceId,
enhance: true,
speed: 1,
stability: 50,
similarity: 75,
output_format: 'mp3',
}),
});
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new Error(`60db /tts-synthesize ${response.status}: ${body.slice(0, 300)}`);
}
const data = await response.json();
if (!data.success || !data.audio_base64) {
throw new Error(`60db /tts-synthesize empty: ${data.message ?? 'unknown'}`);
}
return data.audio_base64;
};

router.get('/voices', async (req, res) => {
try {
getConfig(req); // validate key is present
// 60db's public docs don't expose a /voices listing surface, so we
// serve a curated static list. Customize this when 60db ships a
// dynamic catalog endpoint, or override via SIXTYDB_TTS_VOICE_ID.
res.json([
{
id: DEFAULT_VOICE_ID,
name: '60db Default',
labels: { provider: '60db' },
},
]);
} catch (error) {
console.error('60db voices error:', error);
res
.status(500)
.json({ error: true, message: error.message, code: 'GET_VOICES_ERROR' });
}
});

router.post('/tts', async (req, res) => {
try {
const { text, voiceId } = req.body;
const { apiKey, apiBase } = getConfig(req);
const audio_base64 = await synthesize(
text,
voiceId || DEFAULT_VOICE_ID,
apiBase,
apiKey,
);
res.json({
audio: `data:audio/mp3;base64,${audio_base64}`,
format: 'mp3',
});
} catch (error) {
console.error('60db TTS error:', error);
res
.status(500)
.json({ error: true, message: error.message, code: 'TTS_ERROR' });
}
});

router.post('/tts/scene', async (req, res) => {
try {
const { lines, voiceId } = req.body;
const { apiKey, apiBase } = getConfig(req);
const voice = voiceId || DEFAULT_VOICE_ID;
const audioParts = [];

// Same cue convention as the ElevenLabs route — bracketed lines are
// stage directions and are passed through unsynthesized so the
// frontend can display them on the timeline alongside the audio.
for (const line of lines) {
if (line.startsWith('[')) {
audioParts.push({ type: 'cue', content: line });
} else if (line.trim()) {
const audio_base64 = await synthesize(line, voice, apiBase, apiKey);
audioParts.push({
type: 'audio',
content: `data:audio/mp3;base64,${audio_base64}`,
text: line,
});
}
}
res.json({ parts: audioParts });
} catch (error) {
console.error('60db scene TTS error:', error);
res
.status(500)
.json({ error: true, message: error.message, code: 'SCENE_TTS_ERROR' });
}
});

export default router;

// Exported for any future caller that needs to know the constants without
// reaching into the request handlers (used by settings validation, etc.).
export { DEFAULT_VOICE_ID, DEFAULT_MODEL_ID, DEFAULT_API_BASE };
4 changes: 4 additions & 0 deletions backend/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const apiKeys = {
replicate: process.env.REPLICATE_API_KEY || '',
gemini: process.env.GEMINI_API_KEY || '',
elevenlabs: process.env.ELEVENLABS_API_KEY || '',
sixtydb: process.env.SIXTYDB_API_KEY || '',
};

export const getApiKey = (provider) => apiKeys[provider];
Expand All @@ -34,6 +35,7 @@ const saveKeysToEnv = () => {
REPLICATE_API_KEY=${apiKeys.replicate}
GEMINI_API_KEY=${apiKeys.gemini}
ELEVENLABS_API_KEY=${apiKeys.elevenlabs}
SIXTYDB_API_KEY=${apiKeys.sixtydb}
PORT=${PORT}
`;
fs.writeFileSync(envPath, envContent);
Expand All @@ -49,6 +51,7 @@ import videosRoutes from './routes/videos.js';
import thumbnailRoutes from './routes/thumbnail.js';
import exportRoutes from './routes/export.js';
import elevenlabsRoutes from './routes/elevenlabs.js';
import sixtydbRoutes from './routes/sixtydb.js';
import sessionRoutes from './routes/session.js';

app.use('/api/settings', settingsRoutes);
Expand All @@ -58,6 +61,7 @@ app.use('/api/videos', videosRoutes);
app.use('/api/thumbnail', thumbnailRoutes);
app.use('/api/export', exportRoutes);
app.use('/api/elevenlabs', elevenlabsRoutes);
app.use('/api/sixtydb', sixtydbRoutes);
app.use('/api/session', sessionRoutes);

app.use((err, _req, res, _next) => {
Expand Down