# Add text to speech with an integration | Creght

> Text to speech on Creght with the Azure Speech integration: ctx.tts.azure.speak controls voice, rate, pitch and style, upload returns a permanent file URL, the key never enters the sandbox and text is escaped against SSML injection.

Overview

- [Creght API for AI](/api.md)

Discoverability

- [How to optimize llms.txt](/api/optimize-llms-txt.md)

Site configuration

- [Configure talizen.config.ts](/api/talizen-config.md)
- [Implement domain-based locale routing](/api/domain-locale-routing.md)

Backend

- [Calling external APIs on the server and managing the cache](/api/ssr-external-api-cache.md)
- [Build site backend workflows with Func](/api/func-backend.md)
- [JSON tables: definition, reads, and queries](/api/func-json-tables.md)
- [Uploads: signed direct upload and Func-generated files](/api/func-assets-upload.md)
- [Timeouts and streaming responses](/api/func-timeout-streaming.md)
- [Integrate Alipay PC Web Payment with Func](/api/func-alipay-payment.md)

Integrations

- [Send Email and Verification Codes with Integrations](/api/func-email-integration.md)
- [Take Alipay payments with an integration](/api/func-alipay-integration.md)
- [Take Stripe payments with an integration](/api/func-stripe-integration.md)
- [Call OpenAI models with an integration](/api/func-ai-integration.md)
- [Add text to speech with an integration](/api/func-tts-integration.md)

Auth

- [Require a Verified Email to Sign Up](/api/auth-verified-registration.md)
- [Reset and Change Passwords](/api/auth-password-reset.md)
- [Sign In From a Func](/api/auth-func-login.md)
- [Query users from a Func](/api/func-user-directory.md)

On this page

- [Connect Azure Speech](#connect)
- [speak: synthesise audio](#speak)
- [upload: true is usually what you want](#upload)
- [Why only plain text, never SSML](#ssml)
- [Audio formats](#format)
- [Common voices](#voices)
- [Several channels: via(tag)](#via)
- [Caching and deduplication are yours](#cache)
- [Reading the errors](#errors)
- [What this does not do](#scope)

Integrations/Add text to speech with an integration

# Add text to speech with an integration

Connect the Azure Speech integration and use ctx.tts.azure.speak in Func to turn text into audio, with control over voice, rate, pitch and speaking style. upload stores the file in your project space and returns a permanent URL. The key stays on the server and your text is escaped into SSML for you.

Copy Markdown link

Connect the Azure Speech integration and Func gets `ctx.tts.azure.speak()`: turn text into audio for article narration, podcasts, audiobooks or spoken prompts. The subscription key stays on the server and never appears in your code.

**The one-liner**

`ctx.tts.azure.speak({ text: 'hello world', upload: true })` returns a URL you can drop straight into an `<audio>` tag.

## Connect Azure Speech

First create a **Speech service** resource in the Azure portal:

1. Open [Create Speech service](https://portal.azure.com/#create/Microsoft.CognitiveServicesSpeechServices) and pick a subscription, resource group and region. The free F0 tier is enough to try it.
2. Once created, go to Keys and Endpoint and copy **KEY 1**.
3. Note the **Location/Region**, something like `eastus`, `eastasia`, `southeastasia`.

Then open **Backend → Integrations** in the editor and pick **Azure Speech**:

| Field | What goes in | Required |
| --- | --- | --- |
| Key | KEY 1 from the Keys and Endpoint page | Yes |
| Region | The short region id, e.g. `eastus`. **Not a full URL** | Yes |
| Default voice | Used when a call omits one, defaults to `zh-CN-XiaoxiaoNeural` | No |
| Audio format | Blank means mp3 | No |
| Channel tags | A project can hold several configurations (one per language, say); tags tell them apart | No |

**Why the region is the field people get wrong**

The region becomes part of the request hostname ( `<region>.tts.speech.microsoft.com`). Pasting the whole endpoint URL, or a display name like "East US" with a space in it, makes the whole configuration unusable. The panel and the backend both reject those, but remember it wants the short form `eastus`.

Saving asks Azure for the voice list for real, so a wrong key or region fails right there.

## speak: synthesise audio

The common case is just the text:

```ts
export function read(input, ctx) {
  const audio = ctx.tts.azure.speak(input.text)
  return { audioBase64: audio.audioBase64, mimeType: audio.mimeType }
}
```

Pass an object to control voice, rate and delivery:

```ts
export function read(input, ctx) {
  const audio = ctx.tts.azure.speak({
    text: input.text,
    voice: 'en-US-JennyNeural',
    rate: '+10%',
    pitch: '-2st',
    style: 'cheerful',
    styleDegree: 1.5,
    format: 'audio-24khz-48kbitrate-mono-mp3',
    upload: true,
  })
  return { url: audio.fileUrl }
}
```

| Parameter | Notes |
| --- | --- |
| `text` | Required. **Plain text**, not SSML, see [why SSML is not accepted](#ssml) |
| `voice` | Voice name; omit to use the integration's default |
| `rate` | Speaking rate: `+20%`, `-10%`, `1.2`, or words like `slow` / `fast` |
| `pitch` | Pitch: `+10%`, `-2st` (semitones), `high` / `low` |
| `style` | Speaking style such as `cheerful`, `sad`, `newscast-casual`. Only works on voices that support it |
| `styleDegree` | Style intensity, usually between `0.01` and `2` |
| `language` | Language tag such as `en-US`. Rarely needed, the voice carries it |
| `format` | Output format, mp3 when blank, see [audio formats](#format) |
| `upload` | `true` stores the audio in your project space and returns `fileUrl` |
| `filename` | Only used with `upload`. Defaults to a content hash |

The result:

| Field | Notes |
| --- | --- |
| `fileUrl` | Present with `upload: true`. Ready for `<audio src>` |
| `audioBase64` | Present without `upload`. The raw audio, base64 encoded |
| `mimeType` | e.g. `audio/mpeg` |
| `bytes` | Size of the audio |
| `voice` | The voice that was actually used |
| `format` | The format that was actually used |

## upload: true is usually what you want

Without `upload` the audio comes back into your code as base64. A minute of speech is roughly 500KB, close to 700KB once base64 encoded, which costs Func memory and then costs bandwidth again on the way to the browser.

With `upload: true` the audio goes straight into your project's own file space on the server and you get back a permanent URL:

```ts
const audio = ctx.tts.azure.speak({ text: paragraph, upload: true })
ctx.db.insert('chapters', { text: paragraph, audioUrl: audio.fileUrl })
```

Skip `upload` for one-off prompts you play immediately and never store.

## Why only plain text, never SSML

The Azure API takes SSML. The platform builds it for you and escapes your text on the way in. **You cannot write SSML tags yourself.**

**This is a security boundary, not a shortcut**

The text being synthesised usually comes from user input: comments, manuscripts, form fields. Not escaping it does not raise an error, it allows **injection**. A `</voice><voice name='...'>` in the text swaps the voice for everything after it, and a `<break time='9s'/>` puts nine seconds of silence in the middle of your podcast.

The same applies to `rate`, `pitch` and `style`, which end up as SSML attribute values. The platform validates them against a whitelist, so an odd value fails loudly instead of being pasted in.

If you genuinely need to author whole SSML documents, the capability does not pass them through today. Configure your own key as an environment variable and call the API with `fetch`.

## Audio formats

Blank means mp3, which covers almost everything. Common values:

| Format | Use |
| --- | --- |
| `audio-24khz-48kbitrate-mono-mp3` | The default. Small files, fine for the web |
| `audio-48khz-192kbitrate-mono-mp3` | Better quality, bigger files, good for audiobooks |
| `riff-24khz-16bit-mono-pcm` | WAV, for further processing |
| `ogg-24khz-16bit-mono-opus` | Opus, better quality per bit; check browser support yourself |

## Common voices

| Voice | Notes |
| --- | --- |
| `zh-CN-XiaoxiaoNeural` | Chinese, female. The default, supports several styles |
| `zh-CN-YunxiNeural` | Chinese, male, younger sounding |
| `en-US-JennyNeural` | English, female |
| `en-US-GuyNeural` | English, male |
| `en-GB-SoniaNeural` | British English, female |
| `ja-JP-NanamiNeural` | Japanese, female |

The full list is in [Azure Speech language and voice support](https://learn.microsoft.com/azure/ai-services/speech-service/language-support). Not every voice supports `style`.

## Several channels: via(tag)

A project can hold several configurations, one per language for instance, or a trial tier and a paid tier. Tag each one and pick it with `via()`:

```ts
ctx.tts.azure.via('zh').speak({ text: '你好' })
ctx.tts.azure.via('en').speak({ text: 'hello' })
```

Without `via()` the configuration tagged `default` is used.

## Caching and deduplication are yours

The platform does **not** cache: the same sentence twice costs twice. Keep your own table mapping a text hash to a URL.

```ts
export function speakCached(input, ctx) {
  const key = input.text.trim()
  const hit = ctx.db.query('tts_cache', { where: { text: key }, limit: 1 })
  if (hit.list.length > 0) return { url: hit.list[0].url }

  const audio = ctx.tts.azure.speak({ text: key, upload: true })
  ctx.db.insert('tts_cache', { text: key, url: audio.fileUrl })
  return { url: audio.fileUrl }
}
```

It is not built in because only you know what counts as the same sentence. Does a different voice count? A different rate? Building that judgement into the platform would only produce a version you disagree with.

## Reading the errors

| Error | What to fix |
| --- | --- |
| `tts integration has no api key` | The integration is not connected, or the key was cleared |
| `azure_tts_region is required` | No region |
| `invalid azure_tts_region` | A full URL or a display name with a space; it wants the short form `eastus` |
| `text is required` | The text is empty |
| `invalid rate` / `invalid pitch` / `invalid style` | The value failed the whitelist. See the accepted values above |
| `invalid voice` | The voice name is malformed; it looks like `en-US-JennyNeural` |

Azure's own errors (out of quota, unknown voice, key and region mismatch) keep their message but come back as 400 or 502. An upstream 401 is never passed through as-is.

## What this does not do

| Not included | Why |
| --- | --- |
| Raw SSML | The text usually comes from users, and not escaping it is an injection. See [above](#ssml) |
| Caching and deduplication | Only your site knows what counts as the same sentence |
| Splitting long text | Where to break and whether to emit one file per chapter is a content decision, not an API one |
| Other TTS providers | Azure only for now. Others can be added behind the same interface when someone needs one |

See also: [More integrations](/en/docs/ai/more-integrations.md), [OpenAI models](/api/func-ai-integration.md), [Func backend capabilities](/api/func-backend.md).

> Full page index: [/llms.txt](/llms.txt)
