Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
name: release-please

on:
push:
branches: [master]
pull_request:
branches: [master]

permissions:
contents: read

jobs:
compile:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Set up node
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Enable corepack
run: corepack enable
- name: Compile
run: pnpm install --frozen-lockfile && pnpm typecheck && pnpm build

test:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Set up node
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Enable corepack
run: corepack enable
- name: Test
run: pnpm install --frozen-lockfile && pnpm test

release-please:
needs: [compile, test]
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
steps:
- name: Run release-please
id: release
uses: googleapis/release-please-action@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
config-file: release-please-config.json
manifest-file: .release-please-manifest.json

publish:
needs: release-please
if: needs.release-please.outputs.release_created == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Set up node
uses: actions/setup-node@v4
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- name: Enable corepack
run: corepack enable
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
- name: Publish to npm
env:
TAG_NAME: ${{ needs.release-please.outputs.tag_name }}
run: |
if [[ "$TAG_NAME" == *alpha* ]]; then
npm publish --provenance --access public --tag alpha
elif [[ "$TAG_NAME" == *beta* ]]; then
npm publish --provenance --access public --tag beta
else
npm publish --provenance --access public
fi
3 changes: 3 additions & 0 deletions .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
".": "0.1.0"
}
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# @speechify/vercel

## 0.1.0

- Initial release: Speechify text-to-speech provider implementing the AI SDK `SpeechModelV4` specification (`generateSpeech` support).
- Models: `simba-english`, `simba-multilingual`, `simba-3.0`.
- Speech marks and billable character counts exposed via `providerMetadata.speechify`.
100 changes: 100 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Speechify provider for the Vercel AI SDK

The **[Speechify](https://speechify.ai)** provider for the [Vercel AI SDK](https://ai-sdk.dev) adds Speechify's text-to-speech models to the AI SDK's unified speech interface — switch from OpenAI, ElevenLabs, or Deepgram to Speechify with a one-line model swap. It is a thin bridge over the official [`@speechify/api`](https://www.npmjs.com/package/@speechify/api) client.

## Setup

```bash
npm install @speechify/vercel ai
```

Get an API key from the [Speechify Console](https://console.sws.speechify.com) and set it as `SPEECHIFY_API_KEY`.

## Usage

```ts
import { speechify } from '@speechify/vercel';
import { generateSpeech } from 'ai';

const { audio } = await generateSpeech({
model: speechify.speech(), // simba-3.2, voice "geffen_32"
text: 'Hello from Speechify!',
});
```

To customize the API key, base URL, or headers, create your own provider instance:

```ts
import { createSpeechify } from '@speechify/vercel';

const speechify = createSpeechify({
apiKey: process.env.MY_SPEECHIFY_KEY,
});
```

## Models

| Model ID | Description |
| --- | --- |
| `simba-3.2` | Latest-generation streaming-native model, lower latency and richer expressivity (default). English only; serves a curated voice set. |
| `simba-english` | English-optimized TTS |
| `simba-multilingual` | Multilingual TTS — use this for non-English input |
| `simba-3.0` | Earlier Simba 3 model, still available |

## Voices

The `voice` option takes a Speechify voice ID, including your own cloned voices. Defaults to `geffen_32` when omitted. The `simba-3.2` curated set is currently: `beatrice_32`, `dominic_32`, `edmund_32`, `geffen_32`, `harper_32`, `hugh_32`, `imogen_32`, `wyatt_32`; voices like `george` and `scott` work with the 1.6 models. List available voices with `GET /v1/voices` ([docs](https://docs.speechify.ai/tts/api-reference/v1/voices/get)) — each voice's `models` field shows which models it supports; `simba-3.2` serves a curated subset of shared voices (cloned voices are not in it).

## Output formats

The standard `outputFormat` option accepts either a simple format name — `mp3`, `wav`, `ogg`, `aac`, `pcm` — or a Speechify codec string with sample rate and bitrate, e.g. `mp3_24000_128`, `pcm_16000`, `ulaw_8000` (useful for telephony).

## Speed, emotion, and SSML

Speechify controls prosody through [SSML](https://docs.speechify.ai/docs/ssml). The standard `speed` option is implemented by wrapping your text in `<speak><prosody rate="...">`:

```ts
await generateSpeech({
model: speechify.speech(),
text: 'A bit faster please.',
speed: 1.25,
});
```

If your `text` is already SSML (starts with `<speak`), it is sent unchanged — set rate, pitch, and emotion directly in your markup. The `instructions` option is not supported and produces a warning.

## Provider options

```ts
await generateSpeech({
model: speechify.speech(),
text: 'Hello!',
providerOptions: {
speechify: {
ssml: true, // treat text as SSML, disable speed wrapping
outputFormat: 'mp3_24000_128', // codec string, overrides outputFormat
loudnessNormalization: true,
textNormalization: true,
},
},
});
```

## Speech marks (word timing)

Speechify returns word- and sentence-level timing data with every generation, available via provider metadata:

```ts
const result = await generateSpeech({
model: speechify.speech(),
text: 'Timed speech.',
});

const { speechMarks, billableCharactersCount } =
result.providerMetadata.speechify;
```

## Limitations

- Text-to-speech only — Speechify has no transcription API, so there is no transcription model.
- The AI SDK speech interface is request/response; Speechify's streaming endpoint (`/v1/audio/stream`) is not yet exposed through this provider.
63 changes: 63 additions & 0 deletions examples/smoke.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* Real-API smoke test. Requires SPEECHIFY_API_KEY in the environment.
*
* npm run smoke
*
* Verifies: base URL works, audio decodes to nonzero bytes, speech marks
* arrive in providerMetadata, and a bad key produces a clean APICallError.
*/
import { generateSpeech } from 'ai';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { createSpeechify, speechify } from '../src/index';

async function main() {
const result = await generateSpeech({
// defaults: model simba-3.2, voice "geffen_32"
model: speechify.speech(),
text: 'Hello from the Vercel AI SDK. This is Speechify text to speech.',
outputFormat: 'mp3',
});

const audio = result.audio.uint8Array;
if (audio.length === 0) throw new Error('empty audio');

const outPath = join(import.meta.dirname, 'smoke-output.mp3');
writeFileSync(outPath, audio);

const metadata = result.providerMetadata?.speechify as
| Record<string, unknown>
| undefined;

console.log('audio bytes:', audio.length);
console.log('mediaType:', result.audio.mediaType);
console.log('billableCharactersCount:', metadata?.billableCharactersCount);
console.log(
'speechMarks present:',
metadata?.speechMarks != null && typeof metadata.speechMarks === 'object',
);
console.log('warnings:', result.warnings);
console.log('wrote', outPath);

// error path: bad key must raise a clean AI_APICallError
try {
await generateSpeech({
model: createSpeechify({ apiKey: 'invalid-key' }).speech(),
text: 'should fail',
});
console.error('ERROR: bad key did not throw');
process.exitCode = 1;
} catch (error) {
console.log(
'bad-key error:',
(error as Error).name,
'-',
(error as Error).message.slice(0, 120),
);
}
}

main().catch(error => {
console.error(error);
process.exitCode = 1;
});
66 changes: 66 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
{
"name": "@speechify/vercel",
"version": "0.1.0",
"description": "Speechify text-to-speech provider for the Vercel AI SDK",
"license": "MIT",
"type": "module",
"sideEffects": false,
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"files": [
"dist",
"CHANGELOG.md"
],
"scripts": {
"build": "tsup",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"smoke": "tsx examples/smoke.ts"
},
"dependencies": {
"@ai-sdk/provider": "^4.0.2",
"@ai-sdk/provider-utils": "^5.0.5",
"@speechify/api": "^2.0.0"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
},
"devDependencies": {
"@types/node": "^26.1.0",
"ai": "^7.0.14",
"tsup": "^8.3.5",
"tsx": "^4.19.2",
"typescript": "^5.8.3",
"vitest": "^3.1.4",
"zod": "^4.1.8"
},
"packageManager": "pnpm@10.33.0",
"engines": {
"node": ">=18"
},
"keywords": [
"ai",
"ai-sdk",
"vercel-ai-sdk",
"speechify",
"tts",
"text-to-speech",
"speech"
],
"repository": {
"type": "git",
"url": "git+https://github.com/speechify-ai/vercel-sdk.git"
},
"homepage": "https://docs.speechify.ai",
"publishConfig": {
"access": "public"
}
}
Loading
Loading