diff --git a/config.template.jsonc b/config.template.jsonc index e2cad0ce2..e9f626729 100644 --- a/config.template.jsonc +++ b/config.template.jsonc @@ -329,6 +329,7 @@ "secret_key": "", "region": "us-west-2" }, + "speechify": { "apiKey": "" }, "aws-textract": { "access_key": "", "secret_key": "", diff --git a/src/backend/drivers/ai-tts/TTSDriver.test.ts b/src/backend/drivers/ai-tts/TTSDriver.test.ts index 7df956d98..99dccec54 100644 --- a/src/backend/drivers/ai-tts/TTSDriver.test.ts +++ b/src/backend/drivers/ai-tts/TTSDriver.test.ts @@ -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', ]), ); }); diff --git a/src/backend/drivers/ai-tts/TTSDriver.ts b/src/backend/drivers/ai-tts/TTSDriver.ts index 0cb085985..155e18dc7 100644 --- a/src/backend/drivers/ai-tts/TTSDriver.ts +++ b/src/backend/drivers/ai-tts/TTSDriver.ts @@ -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 = { @@ -60,6 +62,7 @@ const ALIAS_TO_PROVIDER: Record = { '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) { @@ -312,6 +316,29 @@ export class TTSDriver extends PuterDriver { } } + #registerSpeechifyProvider(providers: Record) { + const m = this.services.metering; + const speechify = (providers['speechify'] ?? providers['speechify-tts']) as + | Record + | 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; diff --git a/src/backend/drivers/ai-tts/providers/speechify/SpeechifyTTSProvider.test.ts b/src/backend/drivers/ai-tts/providers/speechify/SpeechifyTTSProvider.test.ts new file mode 100644 index 000000000..e68af8c1f --- /dev/null +++ b/src/backend/drivers/ai-tts/providers/speechify/SpeechifyTTSProvider.test.ts @@ -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 . + */ + +/** + * 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; +let hasCreditsSpy: MockInstance; +let incrementUsageSpy: MockInstance; + +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; + 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; + 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: 'hello', + 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: 'already SSML' }), + ); + + const body = JSON.parse( + (fetchSpy.mock.calls[0]![1] as RequestInit).body as string, + ); + expect(body.input).toBe('already SSML'); + }); + + 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::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(); + }); +}); diff --git a/src/backend/drivers/ai-tts/providers/speechify/SpeechifyTTSProvider.ts b/src/backend/drivers/ai-tts/providers/speechify/SpeechifyTTSProvider.ts new file mode 100644 index 000000000..ebe1554b6 --- /dev/null +++ b/src/backend/drivers/ai-tts/providers/speechify/SpeechifyTTSProvider.ts @@ -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 = { + 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 { + 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 { + return SPEECHIFY_TTS_MODELS.map((model) => ({ + id: model.id, + name: model.name, + provider: 'speechify', + })); + } + + override getReportedCosts(): Record[] { + 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 { + 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 = /]/i.test(text) + ? text + : `${text}`; + + 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, + }; + } +} diff --git a/src/backend/drivers/ai-tts/providers/speechify/costs.ts b/src/backend/drivers/ai-tts/providers/speechify/costs.ts new file mode 100644 index 000000000..a8a30b737 --- /dev/null +++ b/src/backend/drivers/ai-tts/providers/speechify/costs.ts @@ -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 . + */ + +// 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 = { + 'simba-3.2': 1000, + 'simba-english': 1000, + 'simba-multilingual': 1000, +}; diff --git a/src/docs/src/AI/txt2speech.md b/src/docs/src/AI/txt2speech.md index 68dcee450..4c16f163a 100755 --- a/src/docs/src/AI/txt2speech.md +++ b/src/docs/src/AI/txt2speech.md @@ -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 ``` +Use Speechify voices + +```html;ai-txt2speech-speechify + + + + + + + +``` + Compare different engines ```html;ai-txt2speech-engines diff --git a/src/puter-js/src/modules/ai/ai.test.js b/src/puter-js/src/modules/ai/ai.test.js index 0fd5a3b02..0e6e29253 100644 --- a/src/puter-js/src/modules/ai/ai.test.js +++ b/src/puter-js/src/modules/ai/ai.test.js @@ -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' }); diff --git a/src/puter-js/src/modules/ai/lib/ttsProviders.js b/src/puter-js/src/modules/ai/lib/ttsProviders.js index 2bafa44e4..aa8ab39ae 100644 --- a/src/puter-js/src/modules/ai/lib/ttsProviders.js +++ b/src/puter-js/src/modules/ai/lib/ttsProviders.js @@ -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', }; /** diff --git a/src/puter-js/src/modules/ai/tts.js b/src/puter-js/src/modules/ai/tts.js index 2f475cf3b..9fd4d55f6 100644 --- a/src/puter-js/src/modules/ai/tts.js +++ b/src/puter-js/src/modules/ai/tts.js @@ -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'; diff --git a/src/puter-js/types/modules/ai.d.ts b/src/puter-js/types/modules/ai.d.ts index 48d028377..b2d7b7dcc 100644 --- a/src/puter-js/types/modules/ai.d.ts +++ b/src/puter-js/types/modules/ai.d.ts @@ -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;