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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ elevenlabs say "one off" --voice JBFqnCBsd6RMkjVDRZzb --model eleven_flash_v2_5
elevenlabs say "save it" --output out.mp3 # write a file, skip playback
```

Defaults are stored in `~/.elevenlabs/config.json` alongside [data residency](#data-residency). Out of the box `say` uses `eleven_v3` — the most expressive model, and the only family that reads [audio tags](https://elevenlabs.io/docs/overview/capabilities/text-to-speech/best-practices#audio-tags) like `elevenlabs say "[whispers] it worked"` as delivery rather than as words. The audio format follows the player unless `--output-format` or an `--output` filename says otherwise.
Defaults are stored in `~/.elevenlabs/config.json` alongside [data residency](#data-residency). Out of the box `say` uses `eleven_v4` — the most expressive model, and (with v3) the only family that reads [audio tags](https://elevenlabs.io/docs/overview/capabilities/text-to-speech/best-practices#audio-tags) like `elevenlabs say "[whispers] it worked"` as delivery rather than as words. The audio format follows the player unless `--output-format` or an `--output` filename says otherwise.

Playback shells out to whichever player is on the box, preferring ones that read stdin so audio starts before the download finishes: `ffplay`, `mpv`, `afplay` (macOS), `paplay`/`aplay` (Linux), `Media.SoundPlayer` (Windows). Install [ffmpeg](https://ffmpeg.org) or [mpv](https://mpv.io) if none are present, or point `--player` at your own.

Expand Down
11 changes: 5 additions & 6 deletions cli/elevenlabs/workflow/say.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,11 @@ use super::settings;
/// George — the voice the co-generated SDK uses in its own doctests, so a
/// first run with no config still produces something sensible.
pub(super) const DEFAULT_VOICE_ID: &str = "JBFqnCBsd6RMkjVDRZzb";
/// The most expressive model, and the only family that honors audio tags
/// (`[whispers]`, `[laughs]`) rather than reading them aloud as text. Costs
/// roughly 0.8s more to first audio than `eleven_flash_v2_5`, which stays the
/// swap when speed matters more than delivery:
/// `elevenlabs say config model eleven_flash_v2_5`.
pub(super) const DEFAULT_MODEL_ID: &str = "eleven_v3";
/// The most expressive model, and part of the v3/v4 families that honor audio
/// tags (`[whispers]`, `[laughs]`) rather than reading them aloud as text.
/// `eleven_v4_turbo` keeps the tags and is the swap when speed matters more
/// than delivery: `elevenlabs say config model eleven_v4_turbo`.
pub(super) const DEFAULT_MODEL_ID: &str = "eleven_v4";

pub(super) const MP3_FORMAT: &str = "mp3_44100_128";
const WAV_FORMAT: &str = "wav_44100";
Expand Down
51 changes: 51 additions & 0 deletions cli/elevenlabs/workflow/skills.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,13 +162,40 @@ elevenlabs feedback missing-capability "<what you needed>"
```
"#;

/// Appended to the named group skills, keyed by their emitted directory. Left
/// out, `model_id` falls to the API's default (`eleven_multilingual_v2` for
/// speech, `eleven_v3` for dialogue), which trails the model we recommend, and
/// the spec-derived files never name a model for an agent to choose instead.
const MODEL_HINTS: &[(&str, &str)] = &[
(
"elevenlabs-text-to-speech",
r#"
## Model

Pass `--model-id eleven_v4`, the most expressive model, which also reads audio
tags like `[whispers]` as delivery. Without it the API falls back to an older
default. For real-time streaming, use `eleven_v4_turbo` instead.
"#,
),
(
"elevenlabs-text-to-dialogue",
r#"
## Model

Pass `--model-id eleven_v4`, the most expressive model, which also reads audio
tags like `[laughs]` as delivery. Without it the API falls back to `eleven_v3`.
"#,
),
];

/// Every file `generate-skills` should write, spec-derived ones first.
fn skill_files(ctx: &AppContext) -> Result<Vec<(PathBuf, String)>, CliError> {
let shared = PathBuf::from(SHARED_SKILL).join("SKILL.md");
let mut files = generate_skills(ctx.spec(), BIN_NAME, &[]);

let mut appended = false;
let mut pointers = 0usize;
let mut hinted = 0usize;
for (path, content) in files.iter_mut() {
if *path == shared {
// Only when the emitter actually produced the no-auth text. If a
Expand All @@ -180,6 +207,13 @@ fn skill_files(ctx: &AppContext) -> Result<Vec<(PathBuf, String)>, CliError> {
content.push_str(FEEDBACK_SECTION);
appended = true;
} else {
if let Some((_, hint)) = MODEL_HINTS
.iter()
.find(|(dir, _)| path.starts_with(dir))
{
content.push_str(hint);
hinted += 1;
}
content.push_str(GAP_POINTER);
pointers += 1;
}
Expand All @@ -201,6 +235,15 @@ fn skill_files(ctx: &AppContext) -> Result<Vec<(PathBuf, String)>, CliError> {
)));
}

if hinted != MODEL_HINTS.len() {
// A group was renamed upstream, and its skill would silently go back
// to recommending nothing.
return Err(CliError::Other(anyhow::anyhow!(
"expected {} skills to carry a model hint, found {hinted}",
MODEL_HINTS.len()
)));
}

files.extend(CUSTOM_SKILLS.iter().map(|(name, content)| {
(
PathBuf::from(format!("{BIN_NAME}-{name}")).join("SKILL.md"),
Expand Down Expand Up @@ -296,6 +339,14 @@ mod tests {
);
}

#[test]
fn model_hints_recommend_v4() {
for (dir, hint) in MODEL_HINTS {
assert!(dir.starts_with(&format!("{BIN_NAME}-")), "{dir}");
assert!(hint.contains("--model-id eleven_v4"), "{dir}");
}
}

#[test]
fn the_section_states_the_pii_rule() {
assert!(FEEDBACK_SECTION.contains("Never put personal data"));
Expand Down
19 changes: 10 additions & 9 deletions cli/elevenlabs/workflow/skills/say.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: elevenlabs-say
description: Speak text aloud from the terminal with `elevenlabs say`. Use when asked to say/speak/read something out loud, announce or narrate a result, play text as audio, generate a quick voiceover or MP3 from text, make spoken output sound excited/whispered/emotional via v3 audio tags, or set a default voice/model/player for the CLI.
description: Speak text aloud from the terminal with `elevenlabs say`. Use when asked to say/speak/read something out loud, announce or narrate a result, play text as audio, generate a quick voiceover or MP3 from text, make spoken output sound excited/whispered/emotional via v4 audio tags, or set a default voice/model/player for the CLI.
---

# `elevenlabs say`
Expand Down Expand Up @@ -54,13 +54,14 @@ elevenlabs say config voice --unset # back to the built-in
| Key | Config field | Built-in default |
|---|---|---|
| `voice` | `say.voice_id` | `JBFqnCBsd6RMkjVDRZzb` (George) |
| `model` | `say.model_id` | `eleven_v3` |
| `model` | `say.model_id` | `eleven_v4` |
| `output-format` | `say.output_format` | follows the player |
| `player` | `say.player` | first one found on `PATH` |

`eleven_v3` is the most expressive model and the only family that understands
the audio tags below. `eleven_flash_v2_5` is the swap when latency matters
more than delivery — roughly a second quicker to first audio.
`eleven_v4` is the most expressive model, and the v4 and v3 families are the
only ones that understand the audio tags below. `eleven_v4_turbo` is the swap
when latency matters more than delivery — it is built for real time and still
reads tags. `eleven_flash_v2_5` is quicker still, but reads tags as words.

Find voice IDs with `elevenlabs voices search`, and model IDs with
`elevenlabs models list`.
Expand All @@ -79,10 +80,10 @@ elevenlabs say "one off" \

Precedence is always **flag > config file > built-in default**.

## Audio tags (v3 only)
## Audio tags (v4 and v3 only)

`eleven_v3` reads inline square-bracket directions as stage directions rather
than as words, which is what makes a line land as speech instead of narration:
`eleven_v4` (like `eleven_v4_turbo` and `eleven_v3`) reads inline
square-bracket directions as stage directions rather than as words, which is what makes a line land as speech instead of narration:

```bash
elevenlabs say "[excited] The build passed!"
Expand All @@ -99,7 +100,7 @@ Three rough families:
- **Experimental** — `[strong French accent]`, `[sings]`, and effects such as
`[applause]` or `[gunshot]`

Tags belong to the v3 family. On `eleven_flash_v2_5`, `eleven_turbo_v2_5` or
Tags belong to the v4 and v3 families. On `eleven_flash_v2_5`, `eleven_turbo_v2_5` or
`eleven_multilingual_v2` they are not directions — they get read out as
literal text, so `[excited] hello` is spoken as the word "excited" followed by
"hello".
Expand Down
4 changes: 2 additions & 2 deletions cli/elevenlabs/workflow/templates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const DEFAULT_TEMPLATE_JSON: &str = r##"{
"asr": { "quality": "high", "provider": "scribe_realtime", "user_input_audio_format": "pcm_16000", "keywords": [] },
"turn": { "turn_timeout": 7.0, "silence_end_call_timeout": -1.0, "mode": "turn" },
"tts": {
"model_id": "eleven_flash_v2",
"model_id": "eleven_v4_turbo",
"voice_id": "cjVigY5qzO86Huf0OWal",
"supported_voices": [],
"agent_output_audio_format": "pcm_16000",
Expand Down Expand Up @@ -174,7 +174,7 @@ fn minimal_template(name: &str) -> Value {
"text_only": false
},
"tts": {
"model_id": "eleven_flash_v2",
"model_id": "eleven_v4_turbo",
"voice_id": "cjVigY5qzO86Huf0OWal"
}
},
Expand Down
Loading