feat: add Speechify TTS driver (puter-tts) (#3453)
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s

* feat: add Speechify TTS driver

Adds a SpeechifyTTSProvider under the ai-tts driver (mirrors the xAI/
ElevenLabs REST-provider shape), registered in TTSDriver alongside the
existing providers. Wires puter-js txt2speech with provider: "speechify"
support, default voice/model, and the speechify-tts driver alias.

Every outbound request sets Speechify-Caller: puter; base URL is
https://api.speechify.ai only; default model is simba-3.2.

* fix: replace placeholder voice IDs with real simba-3.2/simba-english voices

DEFAULT_VOICE and the starter voice catalog used invalid IDs (henry,
cliff, kristy, george, aria) that the real API rejects with 400. Swapped
in confirmed-real voices from a live GET /v1/voices call: geffen_32,
dominic_32, harper_32, hugh_32, imogen_32, and alec for the
simba-english model-override test.

* fix: purge remaining placeholder voice IDs from client, docs, and types

The previous fix only covered the backend provider — the client-side
default in tts.js, its test, the txt2speech docs page, and the ai.d.ts
type comment all still referenced the invalid henry/cliff/kristy/george/
aria set. All replaced with the live-verified voices (geffen_32 default).
Also corrects the docs link to docs.speechify.ai.
This commit is contained in:
Luke Oliff
2026-07-27 17:04:11 -07:00
committed by GitHub
parent 8732494442
commit 8bb7d59527
11 changed files with 797 additions and 4 deletions
+1
View File
@@ -329,6 +329,7 @@
"secret_key": "",
"region": "us-west-2"
},
"speechify": { "apiKey": "" },
"aws-textract": {
"access_key": "",
"secret_key": "",
@@ -128,6 +128,7 @@ beforeAll(async () => {
},
gemini: { apiKey: 'gem-key' },
xai: { apiKey: 'xai-key' },
speechify: { apiKey: 'speechify-key' },
},
} as never);
driver = server.drivers.aiTts as unknown as TTSDriver;
@@ -191,6 +192,7 @@ describe('TTSDriver provider registration', () => {
'elevenlabs',
'gemini',
'openai',
'speechify',
'xai',
]);
});
@@ -243,6 +245,28 @@ describe('TTSDriver.synthesize provider routing', () => {
expect(openaiSpeechCreateMock).not.toHaveBeenCalled();
});
it('routes via legacy driverAlias (speechify-tts → speechify)', async () => {
fetchSpy.mockResolvedValueOnce(
new Response(
JSON.stringify({
audio_data: Buffer.from('audio').toString('base64'),
audio_format: 'mp3',
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
),
);
await withDriverName('speechify-tts', () =>
driver.synthesize({ text: 'hi' }),
);
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(String(fetchSpy.mock.calls[0]![0])).toBe(
'https://api.speechify.ai/v1/audio/speech',
);
expect(openaiSpeechCreateMock).not.toHaveBeenCalled();
});
it('routes via legacy driverAlias (aws-polly → aws-polly)', async () => {
pollyDispatch();
@@ -295,6 +319,7 @@ describe('TTSDriver list_voices / list_engines', () => {
expect(providers.has('openai')).toBe(true);
expect(providers.has('gemini')).toBe(true);
expect(providers.has('xai')).toBe(true);
expect(providers.has('speechify')).toBe(true);
expect(providers.has('aws-polly')).toBe(true);
});
@@ -321,6 +346,7 @@ describe('TTSDriver list_voices / list_engines', () => {
'eleven_multilingual_v2', // elevenlabs
'gemini-2.5-flash-preview-tts',
'xai-tts',
'simba-3.2', // speechify
'standard', // aws-polly
]),
);
@@ -350,6 +376,7 @@ describe('TTSDriver.getReportedCosts', () => {
'driver:aiTts/aws-polly',
'driver:aiTts/gemini',
'driver:aiTts/xai',
'driver:aiTts/speechify',
]),
);
});
+27
View File
@@ -27,6 +27,7 @@ import { AWSPollyTTSProvider } from './providers/awsPolly/AWSPollyTTSProvider.js
import { ElevenLabsTTSProvider } from './providers/elevenlabs/ElevenLabsTTSProvider.js';
import { GeminiTTSProvider } from './providers/gemini/GeminiTTSProvider.js';
import { OpenAITTSProvider } from './providers/openai/OpenAITTSProvider.js';
import { SpeechifyTTSProvider } from './providers/speechify/SpeechifyTTSProvider.js';
import { XAITTSProvider } from './providers/xai/XAITTSProvider.js';
import type {
ISynthesizeArgs,
@@ -52,6 +53,7 @@ const TTS_ALIASES = [
'elevenlabs-tts',
'gemini-tts',
'xai-tts',
'speechify-tts',
] as const;
type TTSAlias = (typeof TTS_ALIASES)[number];
const ALIAS_TO_PROVIDER: Record<TTSAlias, string> = {
@@ -60,6 +62,7 @@ const ALIAS_TO_PROVIDER: Record<TTSAlias, string> = {
'elevenlabs-tts': 'elevenlabs',
'gemini-tts': 'gemini',
'xai-tts': 'xai',
'speechify-tts': 'speechify',
};
export class TTSDriver extends PuterDriver {
@@ -264,6 +267,7 @@ export class TTSDriver extends PuterDriver {
this.#registerGeminiProvider(providers);
this.#registerXAIProvider(providers);
this.#registerSpeechifyProvider(providers);
}
#registerGeminiProvider(providers: Record<string, unknown>) {
@@ -312,6 +316,29 @@ export class TTSDriver extends PuterDriver {
}
}
#registerSpeechifyProvider(providers: Record<string, unknown>) {
const m = this.services.metering;
const speechify = (providers['speechify'] ?? providers['speechify-tts']) as
| Record<string, unknown>
| undefined;
const speechifyKey =
(speechify?.apiKey as string | undefined) ??
(speechify?.api_key as string | undefined) ??
(speechify?.key as string | undefined);
if (speechifyKey) {
try {
this.#providers['speechify'] = new SpeechifyTTSProvider(m, {
apiKey: speechifyKey,
});
} catch (e) {
console.warn(
'[TTSDriver] Failed to init Speechify TTS provider:',
(e as Error).message,
);
}
}
}
#getDefaultProviderName(): string | null {
const names = Object.keys(this.#providers);
if (names.length === 0) return null;
@@ -0,0 +1,385 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Offline unit tests for SpeechifyTTSProvider.
*
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock
* redis) and constructs SpeechifyTTSProvider directly against the live
* wired `MeteringService`. Speechify has no SDK here — the provider
* calls the REST endpoint via `fetch` — so global `fetch` is spied for
* each request shape assertion.
*/
import { Readable } from 'node:stream';
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
type MockInstance,
} from 'vitest';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import { PuterServer } from '../../../../server.js';
import { setupTestServer } from '../../../../testUtil.js';
import { withTestActor } from '../../../integrationTestUtil.js';
import { SpeechifyTTSProvider } from './SpeechifyTTSProvider.js';
import { SPEECHIFY_TTS_COSTS } from './costs.js';
// ── Test harness ────────────────────────────────────────────────────
let server: PuterServer;
let fetchSpy: MockInstance<typeof fetch>;
let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
let incrementUsageSpy: MockInstance<MeteringService['incrementUsage']>;
beforeAll(async () => {
server = await setupTestServer();
});
afterAll(async () => {
await server?.shutdown();
});
const makeProvider = () =>
new SpeechifyTTSProvider(server.services.metering, { apiKey: 'test-key' });
const audioResponse = (audioData = Buffer.from('audio-bytes').toString('base64'), audioFormat = 'mp3') =>
new Response(JSON.stringify({ audio_data: audioData, audio_format: audioFormat }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch') as MockInstance<typeof fetch>;
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage');
});
afterEach(() => {
vi.restoreAllMocks();
});
// ── Construction ────────────────────────────────────────────────────
describe('SpeechifyTTSProvider construction', () => {
it('throws when no apiKey is supplied', () => {
expect(
() =>
new SpeechifyTTSProvider(server.services.metering, {
apiKey: '',
}),
).toThrow(/API key/i);
});
});
// ── Voice / engine catalog ──────────────────────────────────────────
describe('SpeechifyTTSProvider catalog', () => {
it('listVoices returns the documented Speechify voices with provider=speechify', async () => {
const provider = makeProvider();
const voices = await provider.listVoices();
const ids = voices.map((v) => v.id);
expect(ids).toEqual(
expect.arrayContaining(['geffen_32', 'dominic_32', 'harper_32', 'hugh_32', 'imogen_32']),
);
for (const voice of voices) {
expect(voice.provider).toBe('speechify');
}
});
it('listEngines reports the Simba model family', async () => {
const provider = makeProvider();
const engines = await provider.listEngines();
const ids = engines.map((e) => e.id);
expect(ids).toEqual(
expect.arrayContaining(['simba-3.2', 'simba-english', 'simba-multilingual']),
);
for (const engine of engines) {
expect(engine.provider).toBe('speechify');
}
});
});
// ── Reported costs ──────────────────────────────────────────────────
describe('SpeechifyTTSProvider.getReportedCosts', () => {
it('mirrors every entry in costs.ts as a per-character line item', () => {
const provider = makeProvider();
const reported = provider.getReportedCosts();
expect(reported).toHaveLength(Object.keys(SPEECHIFY_TTS_COSTS).length);
for (const [model, ucentsPerUnit] of Object.entries(SPEECHIFY_TTS_COSTS)) {
expect(reported).toContainEqual({
usageType: `speechify:${model}:character`,
ucentsPerUnit,
unit: 'character',
source: 'driver:aiTts/speechify',
});
}
});
});
// ── test_mode bypass ────────────────────────────────────────────────
describe('SpeechifyTTSProvider.synthesize test_mode', () => {
it('returns the canned sample URL without hitting credits or fetch', async () => {
const provider = makeProvider();
const result = await withTestActor(() =>
provider.synthesize({ text: 'hi', test_mode: true }),
);
expect(result).toEqual({
url: 'https://puter-sample-data.puter.site/tts_example.mp3',
content_type: 'audio',
});
expect(hasCreditsSpy).not.toHaveBeenCalled();
expect(fetchSpy).not.toHaveBeenCalled();
});
});
// ── Argument validation ─────────────────────────────────────────────
describe('SpeechifyTTSProvider.synthesize argument validation', () => {
it('throws 400 when text is missing or blank', async () => {
const provider = makeProvider();
await expect(
withTestActor(() => provider.synthesize({ text: '' })),
).rejects.toMatchObject({ statusCode: 400 });
await expect(
withTestActor(() => provider.synthesize({ text: ' ' })),
).rejects.toMatchObject({ statusCode: 400 });
expect(fetchSpy).not.toHaveBeenCalled();
});
it('throws 400 for an unrecognized model', async () => {
const provider = makeProvider();
await expect(
withTestActor(() =>
provider.synthesize({ text: 'hi', model: 'not-a-real-model' }),
),
).rejects.toMatchObject({ statusCode: 400 });
expect(fetchSpy).not.toHaveBeenCalled();
});
});
// ── Credit gate ─────────────────────────────────────────────────────
describe('SpeechifyTTSProvider.synthesize credit gate', () => {
it('throws 402 BEFORE hitting Speechify when actor lacks credits', async () => {
const provider = makeProvider();
hasCreditsSpy.mockResolvedValueOnce(false);
await expect(
withTestActor(() => provider.synthesize({ text: 'hi' })),
).rejects.toMatchObject({ statusCode: 402 });
expect(fetchSpy).not.toHaveBeenCalled();
});
});
// ── Request shape ───────────────────────────────────────────────────
describe('SpeechifyTTSProvider.synthesize request shape', () => {
it('POSTs to /v1/audio/speech with Bearer auth, Speechify-Caller header, and defaults', async () => {
const provider = makeProvider();
fetchSpy.mockResolvedValueOnce(audioResponse());
await withTestActor(() => provider.synthesize({ text: 'hello' }));
const [url, init] = fetchSpy.mock.calls[0]!;
expect(String(url)).toBe('https://api.speechify.ai/v1/audio/speech');
const initObj = init as RequestInit;
expect(initObj.method).toBe('POST');
const headers = initObj.headers as Record<string, string>;
expect(headers.Authorization).toBe('Bearer test-key');
expect(headers['Speechify-Caller']).toBe('puter');
const body = JSON.parse(initObj.body as string);
expect(body).toEqual({
input: '<speak>hello</speak>',
voice_id: 'geffen_32', // DEFAULT_VOICE
model: 'simba-3.2', // DEFAULT_MODEL
audio_format: 'mp3',
});
});
it('forwards voice and model overrides to the API', async () => {
const provider = makeProvider();
fetchSpy.mockResolvedValueOnce(audioResponse());
await withTestActor(() =>
provider.synthesize({ text: 'hi', voice: 'alec', model: 'simba-english' }),
);
const body = JSON.parse(
(fetchSpy.mock.calls[0]![1] as RequestInit).body as string,
);
expect(body.voice_id).toBe('alec');
expect(body.model).toBe('simba-english');
});
it('does not re-wrap text that is already SSML', async () => {
const provider = makeProvider();
fetchSpy.mockResolvedValueOnce(audioResponse());
await withTestActor(() =>
provider.synthesize({ text: '<speak>already SSML</speak>' }),
);
const body = JSON.parse(
(fetchSpy.mock.calls[0]![1] as RequestInit).body as string,
);
expect(body.input).toBe('<speak>already SSML</speak>');
});
it('decodes base64 audio_data into a readable byte stream', async () => {
const provider = makeProvider();
const raw = Buffer.from('AAA-BBB');
fetchSpy.mockResolvedValueOnce(audioResponse(raw.toString('base64')));
const result = (await withTestActor(() =>
provider.synthesize({ text: 'hi' }),
)) as { stream: Readable; content_type: string; chunked: boolean };
expect(result.chunked).toBe(true);
expect(result.stream).toBeInstanceOf(Readable);
const chunks: Buffer[] = [];
for await (const chunk of result.stream) {
chunks.push(chunk as Buffer);
}
expect(Buffer.concat(chunks).equals(raw)).toBe(true);
});
it('maps audio_format to the canonical content-type', async () => {
const provider = makeProvider();
fetchSpy.mockResolvedValueOnce(
audioResponse(Buffer.from('x').toString('base64'), 'wav'),
);
const result = (await withTestActor(() =>
provider.synthesize({ text: 'hi', output_format: 'wav' }),
)) as { content_type: string };
expect(result.content_type).toBe('audio/wav');
});
});
// ── Cost reporting & metering ───────────────────────────────────────
describe('SpeechifyTTSProvider.synthesize metering', () => {
it('meters character count × per-char ucents under speechify:<model>:character', async () => {
const provider = makeProvider();
fetchSpy.mockResolvedValueOnce(audioResponse());
const text = 'hello world';
await withTestActor(() => provider.synthesize({ text }));
const expectedCost = SPEECHIFY_TTS_COSTS['simba-3.2'] * text.length;
expect(incrementUsageSpy).toHaveBeenCalledTimes(1);
const [, usageType, count, cost] = incrementUsageSpy.mock.calls[0]!;
expect(usageType).toBe('speechify:simba-3.2:character');
expect(count).toBe(text.length);
expect(cost).toBe(expectedCost);
});
it('asks for hasEnoughCredits with the same total it later meters', async () => {
const provider = makeProvider();
fetchSpy.mockResolvedValueOnce(audioResponse());
const text = 'hi there';
await withTestActor(() => provider.synthesize({ text }));
const expectedCost = SPEECHIFY_TTS_COSTS['simba-3.2'] * text.length;
expect(hasCreditsSpy.mock.calls[0]![1]).toBe(expectedCost);
});
});
// ── Error paths ─────────────────────────────────────────────────────
describe('SpeechifyTTSProvider.synthesize error paths', () => {
it('maps upstream 4xx to HttpError 400 upstream_bad_request', async () => {
const provider = makeProvider();
fetchSpy.mockResolvedValueOnce(
new Response('bad request', { status: 400 }),
);
await expect(
withTestActor(() => provider.synthesize({ text: 'hi' })),
).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'upstream_bad_request',
});
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
it('maps upstream 5xx to HttpError 400 upstream_provider_unavailable', async () => {
const provider = makeProvider();
fetchSpy.mockResolvedValueOnce(new Response('oops', { status: 503 }));
await expect(
withTestActor(() => provider.synthesize({ text: 'hi' })),
).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'upstream_provider_unavailable',
});
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
it('maps upstream 429 to HttpError 429 upstream_rate_limited', async () => {
const provider = makeProvider();
fetchSpy.mockResolvedValueOnce(new Response('slow down', { status: 429 }));
await expect(
withTestActor(() => provider.synthesize({ text: 'hi' })),
).rejects.toMatchObject({
statusCode: 429,
legacyCode: 'upstream_rate_limited',
});
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
it('throws 400 when the response has no audio_data', async () => {
const provider = makeProvider();
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({}), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
await expect(
withTestActor(() => provider.synthesize({ text: 'hi' })),
).rejects.toMatchObject({ statusCode: 400 });
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
it('lets fetch network errors bubble so the driver boundary can decide', async () => {
const provider = makeProvider();
fetchSpy.mockRejectedValueOnce(new Error('connection reset'));
await expect(
withTestActor(() => provider.synthesize({ text: 'hi' })),
).rejects.toThrow('connection reset');
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,258 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import { Readable } from 'node:stream';
import { HttpError } from '../../../../core/http/HttpError.js';
import { Context } from '../../../../core/context.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import type { DriverStreamResult } from '../../../meta.js';
import type { ITTSVoice, ITTSEngine, ISynthesizeArgs } from '../../types.js';
import { TTSProvider } from '../TTSProvider.js';
import { SPEECHIFY_TTS_COSTS } from './costs.js';
// Public API base only — never an internal/consumer Speechify endpoint.
const API_BASE = 'https://api.speechify.ai';
const CALLER_HEADER = 'Speechify-Caller';
const CALLER_VALUE = 'puter';
const SAMPLE_AUDIO_URL = 'https://puter-sample-data.puter.site/tts_example.mp3';
const DEFAULT_MODEL = 'simba-3.2';
const DEFAULT_VOICE = 'geffen_32';
const SPEECHIFY_TTS_MODELS = [
{ id: 'simba-3.2', name: 'Simba 3.2' },
{ id: 'simba-english', name: 'Simba English' },
{ id: 'simba-multilingual', name: 'Simba Multilingual' },
];
// Representative starter catalog — verify against Speechify's live
// voices endpoint before this ships upstream.
const SPEECHIFY_TTS_VOICES = [
{ id: 'geffen_32', name: 'Geffen', description: 'Warm, conversational' },
{ id: 'dominic_32', name: 'Dominic', description: 'Deep, narrator' },
{ id: 'harper_32', name: 'Harper', description: 'Bright, upbeat' },
{ id: 'hugh_32', name: 'Hugh', description: 'Calm, professional' },
{ id: 'imogen_32', name: 'Imogen', description: 'Clear, neutral' },
];
const CONTENT_TYPES: Record<string, string> = {
mp3: 'audio/mpeg',
wav: 'audio/wav',
ogg: 'audio/ogg',
aac: 'audio/aac',
};
/**
* Speechify TTS provider. Calls the Speechify `/v1/audio/speech` REST endpoint
* and returns audio as a DriverStreamResult. Every outbound request carries
* `Speechify-Caller: puter` for integration attribution.
*/
export class SpeechifyTTSProvider extends TTSProvider {
readonly providerName = 'speechify';
#apiKey: string;
constructor(meteringService: MeteringService, config: { apiKey: string }) {
super(meteringService, config);
if (!config.apiKey) {
throw new Error('Speechify TTS requires an API key');
}
this.#apiKey = config.apiKey;
}
async listVoices(): Promise<ITTSVoice[]> {
return SPEECHIFY_TTS_VOICES.map((voice) => ({
id: voice.id,
name: voice.name,
description: voice.description,
provider: 'speechify',
supported_models: SPEECHIFY_TTS_MODELS.map((m) => m.id),
}));
}
async listEngines(): Promise<ITTSEngine[]> {
return SPEECHIFY_TTS_MODELS.map((model) => ({
id: model.id,
name: model.name,
provider: 'speechify',
}));
}
override getReportedCosts(): Record<string, unknown>[] {
return Object.entries(SPEECHIFY_TTS_COSTS).map(
([model, ucentsPerUnit]) => ({
usageType: `speechify:${model}:character`,
ucentsPerUnit,
unit: 'character',
source: 'driver:aiTts/speechify',
}),
);
}
async synthesize(
args: ISynthesizeArgs,
): Promise<DriverStreamResult | { url: string; content_type: string }> {
const {
text,
voice: voiceArg,
model: modelArg,
response_format,
output_format,
test_mode,
} = args;
if (test_mode) {
return { url: SAMPLE_AUDIO_URL, content_type: 'audio' };
}
if (typeof text !== 'string' || !text.trim()) {
throw new HttpError(400, 'Missing required field: text', {
legacyCode: 'field_required',
fields: { key: 'text' },
});
}
const model = modelArg || DEFAULT_MODEL;
if (!SPEECHIFY_TTS_MODELS.find(({ id }) => id === model)) {
throw new HttpError(
400,
`Invalid model: ${model}. Expected: ${SPEECHIFY_TTS_MODELS.map(({ id }) => id).join(', ')}`,
{
legacyCode: 'field_invalid',
fields: {
key: 'model',
expected: SPEECHIFY_TTS_MODELS.map(({ id }) => id).join(
', ',
),
got: model,
},
},
);
}
const voice = voiceArg || DEFAULT_VOICE;
const format = output_format || response_format || 'mp3';
const actor = Context.get('actor')!;
const usageType = `speechify:${model}:character`;
const ucentsPerChar = SPEECHIFY_TTS_COSTS[model] ?? 0;
const totalCost = ucentsPerChar * text.length;
const usageAllowed = await this.meteringService.hasEnoughCredits(
actor,
totalCost,
);
if (!usageAllowed) {
throw new HttpError(402, 'Insufficient funds', {
legacyCode: 'insufficient_funds',
});
}
// Speechify's synthesis endpoint expects SSML; wrap plain text so
// callers can keep passing bare strings like every other provider.
const input = /<speak[\s>]/i.test(text)
? text
: `<speak>${text}</speak>`;
const response = await fetch(`${API_BASE}/v1/audio/speech`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.#apiKey}`,
'Content-Type': 'application/json',
[CALLER_HEADER]: CALLER_VALUE,
},
body: JSON.stringify({
input,
voice_id: voice,
model,
audio_format: format,
}),
});
if (!response.ok) {
const errText = await response.text().catch(() => '');
console.error(
`[SpeechifyTTSProvider] API returned ${response.status}: ${errText}`,
);
// Map upstream status to an `upstream_*` HttpError so the
// alarm gate skips it. Mirrors ElevenLabs/xAI's translator —
// 4xx and 5xx both surface as 400 to the client (with the
// appropriate legacyCode), 429 stays 429, auth stays 500.
const legacyCode =
response.status >= 500
? 'upstream_provider_unavailable'
: response.status === 401 || response.status === 403
? 'upstream_auth_failed'
: response.status === 429
? 'upstream_rate_limited'
: 'upstream_bad_request';
const exposedStatus =
legacyCode === 'upstream_rate_limited'
? 429
: legacyCode === 'upstream_auth_failed'
? 500
: 400;
throw new HttpError(
exposedStatus,
errText ||
`Speechify TTS request failed (status ${response.status})`,
{
legacyCode,
fields: {
provider: 'speechify',
upstreamStatus: response.status,
},
},
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data: any = await response.json();
if (!data?.audio_data) {
throw new HttpError(
400,
'Speechify TTS did not return audio data',
{
legacyCode: 'upstream_bad_request',
fields: { provider: 'speechify' },
},
);
}
const buffer = Buffer.from(data.audio_data, 'base64');
const stream = Readable.from(buffer);
const contentType =
CONTENT_TYPES[data.audio_format ?? format] || 'audio/mpeg';
this.meteringService.incrementUsage(
actor,
usageType,
text.length,
totalCost,
);
return {
dataType: 'stream',
content_type: contentType,
chunked: true,
stream,
};
}
}
@@ -0,0 +1,29 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// Speechify TTS pricing: $10.00 per 1M characters across the Simba model
// family (per Speechify's published API pricing).
// $10.00 per 1M chars = 1000 cents per 1M chars
// In microcents: 1000 * 1_000_000 = 1_000_000_000 microcents per 1M chars
// Per character: 1_000_000_000 / 1_000_000 = 1000 microcents per character
export const SPEECHIFY_TTS_COSTS: Record<string, number> = {
'simba-3.2': 1000,
'simba-english': 1000,
'simba-multilingual': 1000,
};
+37 -1
View File
@@ -32,7 +32,7 @@ Additional settings for the generation request. Available options depend on the
| Option | Type | Description |
|--------|------|-------------|
| `provider` | `String` | TTS provider to use. `'aws-polly'` (default), `'openai'`, `'elevenlabs'`, `'gemini'`, `'xai'` |
| `provider` | `String` | TTS provider to use. `'aws-polly'` (default), `'openai'`, `'elevenlabs'`, `'gemini'`, `'xai'`, `'speechify'` |
| `model` | `String` | Model identifier (provider-specific) |
| `voice` | `String` | Voice ID used for synthesis (provider-specific) |
| `test_mode` | `Boolean` | When `true`, returns a sample audio without using credits |
@@ -100,6 +100,18 @@ Text supports inline speech tags like `[pause]`, `[laugh]` and wrapping tags lik
For more details, see the [xAI TTS documentation](https://x.ai/news/grok-stt-and-tts-apis).
#### Speechify Options
Available when `provider: 'speechify'`:
| Option | Type | Description |
|--------|------|-------------|
| `model` | `String` | TTS model. Available: `'simba-3.2'` (default), `'simba-english'`, `'simba-multilingual'` |
| `voice` | `String` | Voice ID. Available: `'geffen_32'` (default), `'dominic_32'`, `'harper_32'`, `'hugh_32'`, `'imogen_32'` |
| `output_format` | `String` | Output format. Available: `'mp3'` (default), `'wav'`, `'ogg'`, `'aac'` |
For more details, see the [Speechify API documentation](https://docs.speechify.ai/).
## Return value
A `Promise` that resolves to an `HTMLAudioElement`. The elements `src` points at a blob or remote URL containing the synthesized audio.
@@ -246,6 +258,30 @@ A `Promise` that resolves to an `HTMLAudioElement`. The elements `src` points
</html>
```
<strong class="example-title">Use Speechify voices</strong>
```html;ai-txt2speech-speechify
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<button id="play">Use Speechify voice</button>
<script>
document.getElementById('play').addEventListener('click', async ()=>{
const audio = await puter.ai.txt2speech(
"Hello! This sample uses the Speechify Geffen voice.",
{
provider: "speechify",
model: "simba-3.2",
voice: "geffen_32"
}
);
audio.play();
});
</script>
</body>
</html>
```
<strong class="example-title">Compare different engines</strong>
```html;ai-txt2speech-engines
+14
View File
@@ -334,6 +334,20 @@ describe('ai.txt2speech driver payloads', () => {
});
});
it('txt2speech routes provider speechify with its defaults', async () => {
FakeXHR.respondWith = () => ({ success: true, result: 'data:audio/mpeg;base64,QUJD' });
await ai.txt2speech('hello', { provider: 'speechify' });
const body = lastBody();
expect(body.driver).toBe('speechify-tts');
expect(body.args).toEqual({
text: 'hello',
provider: 'speechify',
voice: 'geffen_32',
model: 'simba-3.2',
output_format: 'mp3',
});
});
it('txt2speech infers the provider from engine aliases', async () => {
FakeXHR.respondWith = () => ({ success: true, result: 'data:audio/mpeg;base64,QUJD' });
await ai.txt2speech('hello', { engine: 'gemini' });
@@ -14,6 +14,7 @@ export const normalizeTTSProvider = (value) => {
if ( ['elevenlabs', 'eleven', '11labs', '11-labs', 'eleven-labs', 'elevenlabs-tts'].includes(lower) ) return 'elevenlabs';
if ( ['gemini', 'google', 'gemini-tts', 'google-tts'].includes(lower) ) return 'gemini';
if ( ['xai', 'grok', 'x-ai', 'xai-tts', 'grok-tts'].includes(lower) ) return 'xai';
if ( ['speechify', 'speechify-tts', 'simba'].includes(lower) ) return 'speechify';
if ( lower === 'aws' || lower === 'polly' || lower === 'aws-polly' ) return 'aws-polly';
return value;
};
@@ -23,6 +24,7 @@ const TTS_DRIVER_NAMES = {
'elevenlabs': 'elevenlabs-tts',
'gemini': 'gemini-tts',
'xai': 'xai-tts',
'speechify': 'speechify-tts',
};
/**
+15 -1
View File
@@ -11,7 +11,7 @@ import { normalizeTTSProvider, ttsDriverName } from './lib/ttsProviders.js';
const MAX_INPUT_SIZE = 3000;
const VALID_AWS_ENGINES = ['standard', 'neural', 'long-form', 'generative'];
const NAMED_PROVIDERS = ['openai', 'elevenlabs', 'gemini', 'xai'];
const NAMED_PROVIDERS = ['openai', 'elevenlabs', 'gemini', 'xai', 'speechify'];
// Fill in each provider's defaults and rename options to what its driver
// expects. Mutates `options`; returns the effective provider.
@@ -66,6 +66,20 @@ const applyProviderDefaults = (provider, options) => {
options.language = 'en';
}
delete options.engine;
} else if ( provider === 'speechify' ) {
if ( !options.model && typeof options.engine === 'string' ) {
options.model = options.engine;
}
if ( ! options.voice ) {
options.voice = 'geffen_32';
}
if ( ! options.model ) {
options.model = 'simba-3.2';
}
if ( !options.output_format && !options.response_format ) {
options.output_format = 'mp3';
}
delete options.engine;
} else {
provider = 'aws-polly';
+2 -2
View File
@@ -266,11 +266,11 @@ export interface Txt2SpeechOptions {
text?: string;
/** Language code. For AWS Polly defaults to `'en-US'`; for xAI a BCP-47 code defaulting to `'en'` (supports `'auto'`). */
language?: string;
/** Voice ID used for synthesis (provider-specific). Defaults to `'Joanna'` (aws-polly), `'alloy'` (openai), `'21m00Tcm4TlvDq8ikWAM'` (elevenlabs), `'Kore'` (gemini), `'eve'` (xai). */
/** Voice ID used for synthesis (provider-specific). Defaults to `'Joanna'` (aws-polly), `'alloy'` (openai), `'21m00Tcm4TlvDq8ikWAM'` (elevenlabs), `'Kore'` (gemini), `'eve'` (xai), `'geffen_32'` (speechify). */
voice?: string;
/** AWS Polly synthesis engine: `'standard'` (default), `'neural'`, `'long-form'`, or `'generative'`. */
engine?: string;
/** TTS provider: `'aws-polly'` (default), `'openai'`, `'elevenlabs'`, `'gemini'`, or `'xai'`. */
/** TTS provider: `'aws-polly'` (default), `'openai'`, `'elevenlabs'`, `'gemini'`, `'xai'`, or `'speechify'`. */
provider?: string;
/** Model identifier (provider-specific). */
model?: string;