Appearance
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-keyRestart 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
- Create an account at elevenlabs.io
- Create an API key from the API keys section of your dashboard
- 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:
| Status | What it means |
|---|---|
| Preparing audio… | Iris is asking the server for this message's audio |
| Generating audio · 1 of 3 parts ready | Generation is running and playback starts the moment the next part lands |
| Playing while generating · 2 of 3 parts ready | Later 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 free | Every 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 message | What you hear |
|---|---|
**bold**, _italic_, `inline code` | The words, without symbols |
| Headings and list items | Separate 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 tags | Nothing. Inside raw HTML, <pre>, <script>, and <style> blocks are read as "(Code sample omitted.)", entities like & 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:
| Setting | Default | Description |
|---|---|---|
tts.enabled | false | Set via IRIS_TTS_ENABLED |
tts.provider | elevenlabs | Prism provider for speech generation |
tts.model | eleven_v4_turbo | Set via IRIS_TTS_MODEL |
tts.voice | (configured) | Set via IRIS_TTS_VOICE |
tts.voice_settings | all null | Optional stability, similarity_boost, style, and use_speaker_boost overrides |
tts.disk | local | Filesystem disk for saved audio, set via IRIS_TTS_DISK |
tts.queue | audio | Queue 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_timeout | 45 | Seconds 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
| Model | Notes |
|---|---|
eleven_v4_turbo | Default. 90+ languages, low latency, and handles numbers and amounts well |
eleven_v4 | ElevenLabs' most expressive model |
eleven_multilingual_v2 | Previous generation, 29 languages |
eleven_flash_v2_5 | Not recommended: skips number normalization, so "$1,000,000" can come out as "one thousand thousand dollars" |
eleven_v3 | Doesn'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_v4Choosing a Voice
Find voice IDs in your ElevenLabs voice library, then set one in your .env:
bash
IRIS_TTS_VOICE=your-voice-idTo 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=s3S3 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
- Request: You click the speaker icon, and the browser asks Iris for the message's audio
- 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
- Generate: A
GenerateMessageAudiojob on theaudioqueue 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 - 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
- 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:
| Error | Cause | What Iris does |
|---|---|---|
| ElevenLabs rejected the API key | Key is invalid, revoked, or rotated | Stops immediately |
| Character quota used up | Your ElevenLabs plan is out of characters | Stops immediately |
| Plan doesn't allow the voice or model | Usually a Voice Library voice on a free plan | Stops immediately |
| Voice couldn't be found | tts.voice doesn't exist on your account | Stops immediately |
| ElevenLabs rejected the request | Other 4xx response, including invalid voice settings | Stops immediately |
| Audio was generated but couldn't be saved | Writing to the audio disk failed, such as a full disk or an S3 outage | Stops immediately, so ElevenLabs never bills the part twice |
| Rate limited / unreachable / unexpected error | Temporary ElevenLabs or network trouble | Retries 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=falseThe speaker icon disappears from assistant messages. Saved audio stays on disk, ready if you turn the feature back on.