mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-23 22:47:19 +00:00
feat: add Speechify TTS driver (puter-tts) (#3453)
* 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:
@@ -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,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,
|
||||
};
|
||||
@@ -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 element’s `src` points at a blob or remote URL containing the synthesized audio.
|
||||
@@ -246,6 +258,30 @@ A `Promise` that resolves to an `HTMLAudioElement`. The element’s `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
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
Vendored
+2
-2
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user