Skip to content

Text-to-Speech ​

Listen to any assistant message, read aloud by an ElevenLabs voice. Audio starts within a couple of seconds, even for long replies, and Iris saves it the first time it's generated, so replays are instant and never cost you another character.

Features ​

  • Fast start: Long replies are split into parts, and playback begins as soon as the first part is ready
  • Saved audio: Each message is generated once and stored, so replaying it is free
  • A real player: Play, pause, seek, change speed from 0.75× to 2×, and skip 15 seconds (on wider screens, or from your lock screen)
  • Live status: See exactly what's happening, from "Generating audio · 2 of 5 parts ready" to why something failed
  • Lock screen controls: Pause and skip from your phone's lock screen, headphones, or keyboard media keys
  • Readable output: Markdown is converted into natural speech instead of being read symbol by symbol

Setup ​

Add these to your .env:

bash
IRIS_TTS_ENABLED=true
ELEVENLABS_API_KEY=your-elevenlabs-key

Restart Horizon so it starts processing the audio queue, then rebuild your frontend with npm run build. A speaker icon appears on assistant messages once both settings are in place. If the API key is missing, Iris hides the feature instead of showing a button that can only fail.

Getting an ElevenLabs API Key ​

  1. Create an account at elevenlabs.io
  2. Create an API key from the API keys section of your dashboard
  3. Copy the key into your .env

Iris only needs the Text to Speech permission. A restricted key without account, voice, or model read access works fine.

IMPORTANT

Free ElevenLabs plans can't use voices from the shared Voice Library through the API, and commercial use requires a paid plan. On a free plan, pick one of ElevenLabs' default voices or a voice you created yourself (see Choosing a Voice).

Using the Player ​

Click the speaker icon on any assistant message. A player bar appears under the chat header with the message's first line and a status:

StatusWhat it means
Preparing audio…Iris is asking the server for this message's audio
Generating audio · 1 of 3 parts readyGeneration is running and playback starts the moment the next part lands
Playing while generating · 2 of 3 parts readyLater parts are still being generated. This also shows while you're paused
Loading…The player is buffering audio that's already saved
Saved · replays are freeEvery part is stored, and replaying won't call ElevenLabs

The "N of M parts ready" suffix only appears for messages split into more than one part.

The lighter section of the progress bar shows how much audio exists so far. You can seek anywhere inside it. The total length is marked with ~ while it's still an estimate.

The player keeps going when you switch threads, so you can listen to one reply while reading another. It stops when you leave the chat page.

Messages that already have saved audio show their length next to the speaker icon, like 1:23, so you can tell at a glance which replays are free.

TIP

Your playback speed is remembered between sessions. Set it once and every message plays at that speed.

What Gets Read Aloud ​

Iris converts the message's markdown into prose before sending it to ElevenLabs. You hear the words, not the formatting:

In the messageWhat you hear
**bold**, _italic_, `inline code`The words, without symbols
Headings and list itemsSeparate sentences with natural pauses
[the docs](https://...)"the docs"
A bare URL like https://www.github.com/prism-php"github.com"
A fenced code block"(Code sample omitted.)"
A table"(Table omitted.)"
Images and HTML tagsNothing. Inside raw HTML, <pre>, <script>, and <style> blocks are read as "(Code sample omitted.)", entities like &amp; are decoded, and <br> becomes a pause

Tool calls, citations, and attachments aren't part of the message text, so they're never read.

Saved Audio and Costs ​

The first time you play a message, Iris generates its audio and stores it on disk. Every later play streams the stored file, with no ElevenLabs request and no charge. On the local disk your browser also caches the audio files, so replays don't download them again.

Iris regenerates a message's audio only when:

  • The message text changes
  • You change the voice, model, or voice settings
  • The stored files have gone missing

WARNING

Changing tts.model, tts.voice, or tts.voice_settings invalidates all saved audio. Each message regenerates, and is billed again, the next time someone plays it. Pick your voice and model before rolling the feature out to other users.

ElevenLabs bills by character, so you pay for a message the first time it's played and nothing after that. Code blocks and tables are replaced with a short cue, so they don't add to the bill. Check ElevenLabs pricing for current rates.

Configuration ​

TTS settings live in config/iris.php:

SettingDefaultDescription
tts.enabledfalseSet via IRIS_TTS_ENABLED
tts.providerelevenlabsPrism provider for speech generation
tts.modeleleven_v4_turboSet via IRIS_TTS_MODEL
tts.voice(configured)Set via IRIS_TTS_VOICE
tts.voice_settingsall nullOptional stability, similarity_boost, style, and use_speaker_boost overrides
tts.disklocalFilesystem disk for saved audio, set via IRIS_TTS_DISK
tts.queueaudioQueue that generation jobs run on, set via IRIS_TTS_QUEUE. If you change it, add the queue to a supervisor in config/horizon.php
tts.request_timeout45Seconds to wait for a single ElevenLabs request
tts.segment_character_limits[250, 1000, 2500]Maximum characters per part, by position (the last value repeats)

Choosing a Model ​

ModelNotes
eleven_v4_turboDefault. 90+ languages, low latency, and handles numbers and amounts well
eleven_v4ElevenLabs' most expressive model
eleven_multilingual_v2Previous generation, 29 languages
eleven_flash_v2_5Not recommended: skips number normalization, so "$1,000,000" can come out as "one thousand thousand dollars"
eleven_v3Doesn't work with Iris. It rejects the previous_text/next_text context Iris uses to join parts seamlessly

Models are priced differently per character. See ElevenLabs pricing for current rates.

Switch models in your .env:

bash
IRIS_TTS_MODEL=eleven_v4

Choosing a Voice ​

Find voice IDs in your ElevenLabs voice library, then set one in your .env:

bash
IRIS_TTS_VOICE=your-voice-id

To tune how the voice sounds, override voice settings in config/iris-custom.php:

php
// config/iris-custom.php
return [
    'tts' => [
        // Lower stability gives a more expressive, varied delivery
        'voice_settings' => [
            'stability' => 0.4,
            'similarity_boost' => 0.8,
        ],
    ],
];

TIP

See Customization for details on how iris-custom.php merges with core config.

Storing Audio on S3 ​

Saved audio uses the local disk by default. To store it on S3 or another cloud disk, point IRIS_TTS_DISK at it:

bash
IRIS_TTS_DISK=s3

S3 disks also need the Flysystem S3 adapter, which Iris doesn't install by default. Without it, generation fails when the first part is saved:

bash
composer require league/flysystem-aws-s3-v3 "^3.0"

Iris serves local files directly, with the range-request support Safari needs for audio. For cloud disks that support temporary URLs, it redirects to a signed URL that's valid for 30 minutes. Other non-local disks stream through the app, and Safari may not play those because there's no range-request support.

How It Works ​

  1. Request: You click the speaker icon, and the browser asks Iris for the message's audio
  2. Reuse or plan: If saved audio matches the current text, voice, and model, Iris returns it immediately. Otherwise it converts the markdown to speech-friendly text and splits it into parts at paragraph and sentence boundaries
  3. Generate: A GenerateMessageAudio job on the audio queue generates one part, saves it, then queues itself for the next. Each request includes the neighboring text, so separately generated parts sound like one continuous read
  4. Stream status: After every part, Iris broadcasts progress over the chat WebSocket channel. If the WebSocket is down, the player checks for progress every 3 seconds instead
  5. Play: The player starts as soon as part one exists and moves through the rest in order, waiting briefly if it catches up to generation

The parts grow in size (250, then 1,000, then 2,500 characters), so the first audio arrives almost immediately while bigger parts generate during playback.

Reliability ​

Generation is built to survive failures without paying twice:

  • One part per job: Each job finishes well within the worker timeout, so a crash only loses the part in flight
  • Finished parts are kept: A failure or retry never regenerates parts that are already saved
  • No double billing: Each part is claimed atomically, so duplicate jobs can't generate the same part twice
  • Stalled parts can be reclaimed: If a worker dies mid-part, that part becomes claimable again after 75 seconds. Timeouts are caught right away and show a retryable error

If generation stalls with no error, close the player and start the message again. A new play request that finds progress untouched for 90 seconds re-queues generation.

Iris regularly deletes saved audio for messages that no longer exist. See Background Jobs.

Error Handling ​

When something goes wrong, the player tells you what happened and whether trying again will help:

ErrorCauseWhat Iris does
ElevenLabs rejected the API keyKey is invalid, revoked, or rotatedStops immediately
Character quota used upYour ElevenLabs plan is out of charactersStops immediately
Plan doesn't allow the voice or modelUsually a Voice Library voice on a free planStops immediately
Voice couldn't be foundtts.voice doesn't exist on your accountStops immediately
ElevenLabs rejected the requestOther 4xx response, including invalid voice settingsStops immediately
Audio was generated but couldn't be savedWriting to the audio disk failed, such as a full disk or an S3 outageStops immediately, so ElevenLabs never bills the part twice
Rate limited / unreachable / unexpected errorTemporary ElevenLabs or network troubleRetries up to 5 times with backoff, showing a notice in the player, then stops with a retry option

Retrying won't fix a problem that needs a configuration change, so those errors stop right away instead of burning retries. Retries wait 2, 5, 15, then 30 seconds, or the Retry-After time ElevenLabs sends for rate limits (up to 60 seconds). Once Iris stops, click the player (or the message's icon) to retry. Generation resumes from the first missing part.

Rate Limiting ​

Starting audio for a message is limited to 10 requests per minute per user. Starting a message counts even when its audio is already saved, but pausing, resuming, seeking, and skipping within the player don't.

Troubleshooting ​

Speaker icon doesn't appear: Check that IRIS_TTS_ENABLED=true and ELEVENLABS_API_KEY are both set, clear the config cache with php artisan config:clear, and rebuild the frontend with npm run build.

Stuck on "Generating audio": Generation jobs run on the audio queue. Make sure Horizon is running, and restart it after upgrading so it picks up the new queue. Check /horizon for failed GenerateMessageAudio jobs.

"ElevenLabs rejected the API key": Create a new key in your ElevenLabs dashboard and make sure it has the Text to Speech permission.

"The current ElevenLabs plan doesn't allow the configured voice or model": You're on a free plan with a Voice Library voice. Switch to a default voice or one of your own, or upgrade your plan.

Audio won't start on iPhone: Tap the play button in the player bar. Safari sometimes needs a second tap the first time a page plays audio.

"Too many audio requests. Try again in a minute.": You've hit the rate limit. Wait a minute and try again.

Disabling Text-to-Speech ​

Set the environment variable:

bash
IRIS_TTS_ENABLED=false

The speaker icon disappears from assistant messages. Saved audio stays on disk, ready if you turn the feature back on.