From 09919b5c3463487e71858c1e155de4c1401d5b26 Mon Sep 17 00:00:00 2001 From: Paul Asjes Date: Sun, 6 Sep 2026 09:46:22 +0200 Subject: [PATCH 1/3] Adds new say command --- .fernignore | 3 +- Cargo.toml | 2 +- README.md | 29 + cli/elevenlabs/workflow/api.rs | 2 +- cli/elevenlabs/workflow/mod.rs | 2 + cli/elevenlabs/workflow/say.rs | 1148 +++++++++++++++++++++++++++ cli/elevenlabs/workflow/settings.rs | 167 +++- tests/say_test.rs | 273 +++++++ 8 files changed, 1585 insertions(+), 41 deletions(-) create mode 100644 cli/elevenlabs/workflow/say.rs create mode 100644 tests/say_test.rs diff --git a/.fernignore b/.fernignore index ec3f3e3..1065da9 100644 --- a/.fernignore +++ b/.fernignore @@ -21,8 +21,9 @@ assets/ # its test suite leaves untracked build artifacts. elevenlabs-types/.gitignore -# Hand-written live E2E smoke test (tests/wire_test.rs next to it is generated). +# Hand-written tests (tests/wire_test.rs next to them is generated). tests/e2e_smoke.rs +tests/say_test.rs .fern/replay.lock .fern/replay.yml .gitattributes diff --git a/Cargo.toml b/Cargo.toml index 3448201..f8478bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -100,6 +100,7 @@ serde_yaml = "0.9.34" secrecy = "0.10" serde_qs = "1.1.1" sha2 = "0.10" +tempfile = "3" thiserror = "2" webbrowser = "1" rand = "0.8" @@ -148,6 +149,5 @@ serde_yaml = "0.9.34" [dev-dependencies] serial_test = "3.4.0" -tempfile = "3" wiremock = "0.6" tokio = { version = "1", features = ["full"] } diff --git a/README.md b/README.md index 43c1658..11b940b 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ The CLI does two things: - [Installation](#installation) - [Authentication](#authentication) - [Quick start](#quick-start) +- [Speaking text](#speaking-text) - [Agents as Code](#agents-as-code) - [Data residency](#data-residency) - [UI components](#ui-components) @@ -112,6 +113,34 @@ elevenlabs Run `elevenlabs --help` to see available methods for a resource. +## Speaking text + +Turn text into speech and play it, without picking a file path or finding a player: + +```bash +elevenlabs say "this came from the terminal" +echo "build finished" | elevenlabs say # or: elevenlabs say - +``` + +The voice, model, audio format and player default to whatever you have stored, and each can be overridden per run: + +```bash +elevenlabs say config # show the current defaults +elevenlabs say config voice 21m00Tcm4TlvDq8ikWAM +elevenlabs say config model eleven_multilingual_v2 +elevenlabs say config player mpv +elevenlabs say config voice --unset # back to the built-in default + +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_flash_v2_5` — the low-latency model — and 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. + +> `config` is a subcommand, so speaking that exact word needs `elevenlabs say -- config`. + ## Agents as Code Manage Conversational AI agents from local configuration files. `elevenlabs agents init` scaffolds a project; agent configs live as JSON on disk and sync to ElevenLabs. Pulled configs are stored as raw wire JSON and pushed back verbatim, so they round-trip losslessly. diff --git a/cli/elevenlabs/workflow/api.rs b/cli/elevenlabs/workflow/api.rs index ba4fce9..bb1e5f3 100644 --- a/cli/elevenlabs/workflow/api.rs +++ b/cli/elevenlabs/workflow/api.rs @@ -117,7 +117,7 @@ fn request_options() -> Option { /// {"message": ..., "status": ...}}`, FastAPI's `{"detail": [{"msg": ...}]}`, /// and bare `{"message": ..., "status": ...}`. Fall back to the whole body so /// an unrecognized shape is still shown rather than swallowed. -fn api_error_message(body: &Value) -> String { +pub fn api_error_message(body: &Value) -> String { let detail = body.get("detail"); if let Some(s) = detail.and_then(Value::as_str) { return s.to_string(); diff --git a/cli/elevenlabs/workflow/mod.rs b/cli/elevenlabs/workflow/mod.rs index 1a33e5c..b4671e9 100644 --- a/cli/elevenlabs/workflow/mod.rs +++ b/cli/elevenlabs/workflow/mod.rs @@ -18,6 +18,7 @@ mod api; mod components; mod project; mod residency; +mod say; mod settings; mod templates; mod tests; @@ -32,5 +33,6 @@ pub fn register(app: CliApp) -> CliApp { let app = tools::register(app); let app = tests::register(app); let app = residency::register(app); + let app = say::register(app); components::register(app) } diff --git a/cli/elevenlabs/workflow/say.rs b/cli/elevenlabs/workflow/say.rs new file mode 100644 index 0000000..a491772 --- /dev/null +++ b/cli/elevenlabs/workflow/say.rs @@ -0,0 +1,1148 @@ +//! The `say` command: turn text into speech and play it, straight from +//! the terminal. +//! +//! `elevenlabs text-to-speech convert` can already reach the API, but it +//! makes you supply a voice ID, a model ID, an output format and a file +//! path every time, and then find your own player. `say` remembers the +//! first four in `~/.elevenlabs/config.json` (via [`super::settings`], +//! the same file `residency` writes) and pipes the audio into whatever +//! player is on the box. +//! +//! Playback shells out rather than linking an audio stack: `rodio` pulls +//! in `cpal` -> `alsa-sys`, which needs `libasound2-dev` at build time and +//! would break the static musl targets this workspace distributes (see the +//! `cfg(target_env = "musl")` gates in Cargo.toml). Spawning is done the +//! way `super::components` does it — argv passed individually, never +//! through a shell. + +use std::io::{IsTerminal, Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; + +use clap::{Args as _, FromArgMatches as _}; +use fern_cli_sdk::app::CliApp; +use fern_cli_sdk::error::CliError; +use fern_cli_sdk::openapi::AppContext; +use fern_cli_sdk::sdk_executor::SdkRequestExecutor; +use futures_util::StreamExt; +use serde_json::{json, Value}; + +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. +const DEFAULT_VOICE_ID: &str = "JBFqnCBsd6RMkjVDRZzb"; +/// Latency beats fidelity for a terminal one-liner. `eleven_multilingual_v2` +/// is the quality swap: `elevenlabs say config model eleven_multilingual_v2`. +const DEFAULT_MODEL_ID: &str = "eleven_flash_v2_5"; + +const MP3_FORMAT: &str = "mp3_44100_128"; +const WAV_FORMAT: &str = "wav_44100"; + +/// Section holding `say`'s settings inside `~/.elevenlabs/config.json`. +const SECTION: &str = "say"; + +// ── Settings ──────────────────────────────────────────────────────── + +/// One configurable default, as the user types it and as it is stored. +struct SettingSpec { + /// What `elevenlabs say config ` accepts. + cli_key: &'static str, + /// Key inside the `say` object in the config file. + json_key: &'static str, + label: &'static str, + /// Built-in fallback, or `None` when the value is derived at runtime. + default: Option<&'static str>, +} + +const SETTINGS: &[SettingSpec] = &[ + SettingSpec { + cli_key: "voice", + json_key: "voice_id", + label: "Voice", + default: Some(DEFAULT_VOICE_ID), + }, + SettingSpec { + cli_key: "model", + json_key: "model_id", + label: "Model", + default: Some(DEFAULT_MODEL_ID), + }, + SettingSpec { + cli_key: "output-format", + json_key: "output_format", + label: "Format", + default: None, + }, + SettingSpec { + cli_key: "player", + json_key: "player", + label: "Player", + default: None, + }, +]; + +fn spec_for(cli_key: &str) -> Option<&'static SettingSpec> { + SETTINGS.iter().find(|s| s.cli_key == cli_key) +} + +fn read_configured(json_key: &str) -> Option { + settings::read_setting(&[SECTION, json_key]).filter(|v| !v.trim().is_empty()) +} + +/// Flag > config file > built-in default. A whitespace-only value counts as +/// unset at every layer — the same trap `residency::base_url_to_export` +/// guards, since an empty voice ID would build a URL like `/v1/…//stream`. +fn resolve<'a>(flag: Option<&'a str>, configured: Option<&'a str>, default: &'a str) -> &'a str { + for candidate in [flag, configured] { + if let Some(v) = candidate { + if !v.trim().is_empty() { + return v.trim(); + } + } + } + default +} + +// ── Output formats ────────────────────────────────────────────────── + +/// The codec half of an `output_format` (`mp3_44100_128` -> `mp3`). +/// +/// Doubles as validation: rather than pinning the full enum from +/// `elevenlabs-types` — which would reject any format the API adds before +/// the next regeneration — this accepts a known codec plus numeric +/// parameters, which catches typos without blocking new formats. +fn codec_of(format: &str) -> Option<&'static str> { + let (codec, rest) = format.split_once('_')?; + let codec = match codec { + "mp3" => "mp3", + "wav" => "wav", + "pcm" => "pcm", + "opus" => "opus", + "ulaw" => "ulaw", + "alaw" => "alaw", + _ => return None, + }; + let numeric = !rest.is_empty() + && rest + .split('_') + .all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit())); + numeric.then_some(codec) +} + +/// File extension to write for a format, so `afplay` and PowerShell's +/// `SoundPlayer` pick the right decoder from the name. +fn extension_for(format: &str) -> &'static str { + match codec_of(format) { + Some("wav") => "wav", + Some("opus") => "opus", + Some("pcm") | Some("ulaw") | Some("alaw") => "raw", + _ => "mp3", + } +} + +/// The format implied by an output filename, when the user did not name one. +fn format_from_extension(path: &Path) -> Option<&'static str> { + let ext = path.extension()?.to_str()?.to_ascii_lowercase(); + match ext.as_str() { + "mp3" => Some(MP3_FORMAT), + "wav" => Some(WAV_FORMAT), + "opus" | "ogg" => Some("opus_48000_128"), + "pcm" | "raw" => Some("pcm_44100"), + "ulaw" => Some("ulaw_8000"), + "alaw" => Some("alaw_8000"), + _ => None, + } +} + +/// Pick the wire format. +/// +/// An explicit `--output-format` always wins. Otherwise a `--output` +/// filename decides, ahead of the configured default — writing `out.wav` +/// and getting MP3 bytes inside it is worse than ignoring the config. With +/// neither, the player decides: `aplay`/`paplay`/`SoundPlayer` only handle +/// WAV. +fn output_format_for( + explicit: Option<&str>, + configured: Option<&str>, + output: Option<&Path>, + container: Container, +) -> String { + if let Some(fmt) = explicit.map(str::trim).filter(|f| !f.is_empty()) { + return fmt.to_string(); + } + if let Some(fmt) = output.and_then(format_from_extension) { + return fmt.to_string(); + } + if let Some(fmt) = configured.map(str::trim).filter(|f| !f.is_empty()) { + return fmt.to_string(); + } + match container { + Container::WavOnly => WAV_FORMAT.to_string(), + Container::Any => MP3_FORMAT.to_string(), + } +} + +// ── Players ───────────────────────────────────────────────────────── + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Container { + /// Decodes whatever we send it. + Any, + /// WAV only — the format has to follow. + WavOnly, +} + +/// A player we know how to drive. +#[derive(Clone, Copy, Debug)] +struct PlayerSpec { + program: &'static str, + /// Fixed arguments that precede the target. + args: &'static [&'static str], + /// Reads audio from stdin (so it can start before the download finishes). + reads_stdin: bool, + container: Container, +} + +/// Preference order. stdin-capable players come first: they are what makes +/// streaming pay off, since playback starts on the first chunk instead of +/// after the last one. +const PLAYERS: &[PlayerSpec] = &[ + PlayerSpec { + program: "ffplay", + args: &["-nodisp", "-autoexit", "-loglevel", "error", "-i"], + reads_stdin: true, + container: Container::Any, + }, + PlayerSpec { + program: "mpv", + args: &["--no-video", "--really-quiet"], + reads_stdin: true, + container: Container::Any, + }, + PlayerSpec { + program: "afplay", + args: &[], + reads_stdin: false, + container: Container::Any, + }, + PlayerSpec { + program: "paplay", + args: &[], + reads_stdin: false, + container: Container::WavOnly, + }, + PlayerSpec { + program: "aplay", + args: &["-q"], + reads_stdin: false, + container: Container::WavOnly, + }, + PlayerSpec { + program: "powershell", + args: &[], + reads_stdin: false, + container: Container::WavOnly, + }, +]; + +fn spec_for_program(program: &str) -> Option<&'static PlayerSpec> { + PLAYERS.iter().find(|p| p.program == program) +} + +/// Players worth probing on this OS. `powershell` is Windows-only — +/// PowerShell Core exists on Linux but `Media.SoundPlayer` does not. +fn candidates() -> impl Iterator { + PLAYERS.iter().filter(|p| match p.program { + "powershell" => cfg!(windows), + "afplay" => cfg!(target_os = "macos"), + _ => true, + }) +} + +/// A player chosen for this run. Owns its program name because `--player` +/// and the config file supply names we do not know at compile time. +#[derive(Clone, Debug)] +struct Player { + program: String, + args: Vec, + reads_stdin: bool, + container: Container, +} + +impl Player { + fn from_spec(spec: &PlayerSpec) -> Self { + Self { + program: spec.program.to_string(), + args: spec.args.iter().map(|s| (*s).to_string()).collect(), + reads_stdin: spec.reads_stdin, + container: spec.container, + } + } + + /// A player the user named that is not in [`PLAYERS`]. We know nothing + /// about it, so assume the conservative shape: a file argument, and a + /// format it is most likely to understand. + fn unknown(program: &str) -> Self { + Self { + program: program.to_string(), + args: Vec::new(), + reads_stdin: false, + container: Container::Any, + } + } +} + +/// Is `program` runnable? Mirrors what `Command::spawn` will resolve, so a +/// missing player is reported before any API call is made. +fn on_path(program: &str) -> bool { + let has_separator = program.contains('/') || (cfg!(windows) && program.contains('\\')); + if has_separator { + return Path::new(program).is_file(); + } + let Some(path) = std::env::var_os("PATH") else { + return false; + }; + std::env::split_paths(&path).any(|dir| { + if dir.as_os_str().is_empty() { + return false; + } + if dir.join(program).is_file() { + return true; + } + // Windows resolves bare names through PATHEXT. + cfg!(windows) && dir.join(format!("{program}.exe")).is_file() + }) +} + +/// `--player` > configured player > the first candidate on PATH. +/// +/// A name the user supplied is honored even if we do not recognize it, so +/// `--player my-wrapper` works; only "nothing at all" is an error. +fn resolve_player(explicit: Option<&str>, configured: Option<&str>) -> Result { + let named = [explicit, configured] + .into_iter() + .flatten() + .map(str::trim) + .find(|v| !v.is_empty()); + + if let Some(name) = named { + if !on_path(name) { + return Err(CliError::Validation(format!( + "Audio player '{name}' was not found on PATH. Install it, pass \ + --player , or clear the default with \ + 'elevenlabs say config player --unset'." + ))); + } + return Ok(spec_for_program(name) + .map(Player::from_spec) + .unwrap_or_else(|| Player::unknown(name))); + } + + candidates() + .find(|spec| on_path(spec.program)) + .map(Player::from_spec) + .ok_or_else(|| { + CliError::Validation(format!( + "No audio player found. Install ffmpeg (for ffplay) or mpv, or pass \ + --player . Looked for: {}. To skip playback entirely, use \ + --output .", + candidates() + .map(|s| s.program) + .collect::>() + .join(", ") + )) + }) +} + +/// Build the argv for a player invocation. +/// +/// Arguments are passed individually and never through a shell, so nothing +/// in `target` is re-parsed. PowerShell is the one exception in shape — it +/// takes a script string — so the path is embedded in a single-quoted +/// literal with `'` doubled, PowerShell's own escape. +fn player_argv(player: &Player, target: &str) -> Vec { + if player.program == "powershell" { + let quoted = target.replace('\'', "''"); + return vec![ + "-NoProfile".to_string(), + "-Command".to_string(), + format!("(New-Object Media.SoundPlayer '{quoted}').PlaySync()"), + ]; + } + let mut argv = player.args.clone(); + argv.push(target.to_string()); + argv +} + +fn spawn_player(player: &Player, target: &str, stdin: Stdio) -> Result { + Command::new(&player.program) + .args(player_argv(player, target)) + .stdin(stdin) + .spawn() + .map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + CliError::Validation(format!( + "Audio player '{}' was not found on PATH.", + player.program + )) + } else { + CliError::Other(anyhow::anyhow!("Could not run {}: {e}", player.program)) + } + }) +} + +// ── Text input ────────────────────────────────────────────────────── + +#[derive(Debug, PartialEq, Eq)] +enum TextSource { + Literal(String), + Stdin, +} + +/// Where the text comes from: the positional arguments, or stdin when the +/// user wrote `-` or piped something in. +fn text_source(positional: &[String], stdin_is_tty: bool) -> Result { + if positional.len() == 1 && positional[0] == "-" { + return Ok(TextSource::Stdin); + } + if positional.is_empty() { + return if stdin_is_tty { + Err(CliError::Validation( + "No text to speak. Try: elevenlabs say \"hello from the terminal\" \ + (or pipe text in, or pass -)." + .to_string(), + )) + } else { + Ok(TextSource::Stdin) + }; + } + Ok(TextSource::Literal(positional.join(" "))) +} + +fn read_text(source: TextSource) -> Result { + let text = match source { + TextSource::Literal(t) => t, + TextSource::Stdin => { + let mut buf = String::new(); + std::io::stdin() + .read_to_string(&mut buf) + .map_err(|e| CliError::Other(anyhow::anyhow!("Could not read stdin: {e}")))?; + buf + } + }; + if text.trim().is_empty() { + return Err(CliError::Validation( + "Nothing to speak — the text was empty.".to_string(), + )); + } + Ok(text) +} + +// ── The request ───────────────────────────────────────────────────── + +/// Run a future to completion from a synchronous handler. +/// +/// Same shape as `fern_cli_sdk::sdk_executor::block_on` — `block_in_place` +/// parks this worker thread so a nested `block_on` is legal — but generic +/// over the output so the body can return [`CliError`] directly instead of +/// being forced through `SdkError`. +fn run_async(future: F) -> F::Output { + tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(future)) +} + +/// Send the request and validate the status, returning the response with +/// its body still unread. +/// +/// This goes through the CLI executor directly rather than +/// `client.text_to_speech.stream(...)`, which returns a `ByteStream` that +/// *discards the HTTP status*. `CliExecutor` does not reject non-2xx on the +/// executor path (see the note in `super::api`, where a 401 once surfaced +/// as "no agents found" with exit 0) — so with the SDK method a 401 would +/// be piped into the audio player as JSON. +/// +/// Kept separate from [`pump`] so the status is known *before* a player is +/// spawned: a failed request must never flash an audio window or make +/// ffplay complain about the JSON error body. +fn start_stream( + ctx: &AppContext, + voice_id: &str, + body: Value, + output_format: &str, +) -> Result { + let base = elevenlabs_sdk::ClientConfig::default().base_url; + let url = format!( + "{}/v1/text-to-speech/{}/stream", + base.trim_end_matches('/'), + percent_encoding::utf8_percent_encode(voice_id, percent_encoding::NON_ALPHANUMERIC), + ); + // The executor rewrites scheme/host/port from the resolved base URL, so + // residency, ELEVENLABS_BASE_URL and --base-url all still win over the + // default host used here. + let request = reqwest::Client::new() + .post(url) + .query(&[("output_format", output_format)]) + .json(&body) + .build() + .map_err(|e| CliError::Other(anyhow::anyhow!("Could not build the request: {e}")))?; + + let executor = ctx.build_sdk_executor(); + + run_async(async move { + let response = SdkRequestExecutor::execute(&*executor, request) + .await + .map_err(|e| e.into_cli_error())?; + + let status = response.status(); + if status.is_success() { + return Ok(response); + } + let body = response.bytes().await.unwrap_or_default(); + let parsed: Value = serde_json::from_slice(&body).unwrap_or(Value::Null); + Err(CliError::Api { + code: status.as_u16(), + message: super::api::api_error_message(&parsed), + reason: format!("http_{}", status.as_u16()), + details: (!parsed.is_null()).then_some(parsed), + help: None, + }) + }) +} + +/// Copy an already-validated response body into `sink`, chunk by chunk. +fn pump(response: reqwest::Response, sink: &mut dyn Write) -> Result<(), CliError> { + run_async(async move { + let mut chunks = Box::pin(response.bytes_stream()); + while let Some(chunk) = chunks.next().await { + let chunk = + chunk.map_err(|e| CliError::Network(format!("Audio stream failed: {e}")))?; + match sink.write_all(&chunk) { + Ok(()) => {} + // The player was closed (q in ffplay, Ctrl-C in mpv). That is + // a normal way to stop listening, not a failure. + Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => return Ok(()), + Err(e) => { + return Err(CliError::Other(anyhow::anyhow!( + "Could not write audio: {e}" + ))) + } + } + } + sink.flush() + .map_err(|e| CliError::Other(anyhow::anyhow!("Could not flush audio: {e}"))) + }) +} + +// ── `say` ─────────────────────────────────────────────────────────── + +#[derive(clap::Args)] +struct SayArgs { + /// Text to speak. Pass `-`, or pipe text in, to read from stdin. + #[arg(value_name = "TEXT")] + text: Vec, + + /// Voice ID for this run. Overrides `elevenlabs say config voice`. + #[arg(long, value_name = "ID")] + voice: Option, + + /// Model ID for this run. Overrides `elevenlabs say config model`. + #[arg(long, value_name = "ID")] + model: Option, + + /// Audio format, e.g. mp3_44100_128 or wav_44100. + #[arg(long, value_name = "FORMAT")] + output_format: Option, + + /// Write the audio to a file instead of playing it. + #[arg(short = 'o', long, value_name = "PATH")] + output: Option, + + /// Player command to use, e.g. ffplay, mpv, afplay. + #[arg(long, value_name = "COMMAND")] + player: Option, +} + +fn handle_say(args: SayArgs, ctx: &AppContext) -> Result<(), CliError> { + let text = read_text(text_source(&args.text, std::io::stdin().is_terminal())?)?; + + let configured_voice = read_configured("voice_id"); + let configured_model = read_configured("model_id"); + let configured_format = read_configured("output_format"); + let configured_player = read_configured("player"); + + let voice_id = resolve( + args.voice.as_deref(), + configured_voice.as_deref(), + DEFAULT_VOICE_ID, + ) + .to_string(); + let model_id = resolve( + args.model.as_deref(), + configured_model.as_deref(), + DEFAULT_MODEL_ID, + ) + .to_string(); + + // Resolve the player before generating anything: "no player installed" + // should not cost an API call. Skipped entirely for --output. + let player = match args.output { + Some(_) => None, + None => Some(resolve_player( + args.player.as_deref(), + configured_player.as_deref(), + )?), + }; + + let output_format = output_format_for( + args.output_format.as_deref(), + configured_format.as_deref(), + args.output.as_deref(), + player.as_ref().map_or(Container::Any, |p| p.container), + ); + if codec_of(&output_format).is_none() { + return Err(CliError::Validation(format!( + "Invalid output format '{output_format}'. Expected something like \ + mp3_44100_128, wav_44100 or pcm_24000." + ))); + } + if let Some(p) = &player { + if p.container == Container::WavOnly && codec_of(&output_format) != Some("wav") { + return Err(CliError::Validation(format!( + "The '{}' player only handles WAV, but the format is '{output_format}'. \ + Use --output-format {WAV_FORMAT}, or a player that decodes it (ffplay, mpv).", + p.program + ))); + } + } + + let body = json!({ "text": text, "model_id": model_id }); + + // Tag the User-Agent with `cmd/say` for the duration of this command, + // the way every other hand-written command does. + let _scope = super::api::command_scope("say"); + + // Send and check the status before anything is spawned or created, so a + // 401 costs the user nothing but an error message. + let response = start_stream(ctx, &voice_id, body, &output_format)?; + + match (&args.output, &player) { + (Some(path), _) => { + let mut file = std::fs::File::create(path).map_err(|e| { + CliError::Other(anyhow::anyhow!("Could not create {}: {e}", path.display())) + })?; + pump(response, &mut file)?; + eprintln!("Saved to {}", path.display()); + Ok(()) + } + (None, Some(player)) if player.reads_stdin => { + let mut child = spawn_player(player, "-", Stdio::piped())?; + let mut stdin = child + .stdin + .take() + .ok_or_else(|| CliError::Other(anyhow::anyhow!("Player stdin was not piped")))?; + let result = pump(response, &mut stdin); + // Closing stdin is what tells the player the stream ended. + drop(stdin); + let wait = child.wait(); + result?; + wait.map_err(|e| { + CliError::Other(anyhow::anyhow!( + "Could not wait for {}: {e}", + player.program + )) + })?; + Ok(()) + } + (None, Some(player)) => { + // File-based player: buffer the stream, then play the file. + let mut temp = tempfile::Builder::new() + .prefix("elevenlabs-say-") + .suffix(&format!(".{}", extension_for(&output_format))) + .tempfile() + .map_err(|e| { + CliError::Other(anyhow::anyhow!("Could not create a temporary file: {e}")) + })?; + pump(response, temp.as_file_mut())?; + temp.as_file_mut() + .sync_all() + .map_err(|e| CliError::Other(anyhow::anyhow!("Could not flush audio: {e}")))?; + let path = temp.path().to_string_lossy().to_string(); + let status = spawn_player(player, &path, Stdio::null())? + .wait() + .map_err(|e| { + CliError::Other(anyhow::anyhow!( + "Could not wait for {}: {e}", + player.program + )) + })?; + if !status.success() { + return Err(CliError::Other(anyhow::anyhow!( + "{} exited with {}", + player.program, + status + ))); + } + Ok(()) + } + (None, None) => unreachable!("a player is resolved whenever --output is absent"), + } +} + +// ── `say config` ──────────────────────────────────────────────────── + +#[derive(clap::Args)] +struct ConfigArgs { + /// Setting to read or change: voice, model, output-format or player. + /// Omit to show everything. + key: Option, + + /// New value. Omit to show the current one. + value: Option, + + /// Clear the setting and fall back to the default. + #[arg(long, conflicts_with = "value")] + unset: bool, +} + +fn validate_value(spec: &SettingSpec, value: &str) -> Result<(), CliError> { + // Everything here is echoed back to a terminal by `say config`, and a + // player name is executed, so refuse control characters outright. + if value.chars().any(char::is_control) { + return Err(CliError::Validation(format!( + "Invalid {}: control characters are not allowed.", + spec.cli_key + ))); + } + if value.trim().is_empty() { + return Err(CliError::Validation(format!( + "Invalid {}: the value is empty. Use --unset to clear it.", + spec.cli_key + ))); + } + match spec.cli_key { + "output-format" => { + if codec_of(value.trim()).is_none() { + return Err(CliError::Validation(format!( + "Invalid output format '{value}'. Expected something like \ + mp3_44100_128, wav_44100 or pcm_24000." + ))); + } + } + _ => { + if value.trim().split_whitespace().count() > 1 { + return Err(CliError::Validation(format!( + "Invalid {}: '{value}' contains whitespace.", + spec.cli_key + ))); + } + } + } + Ok(()) +} + +/// How a value is being sourced, for the `say config` listing. +fn describe(spec: &SettingSpec) -> String { + match (read_configured(spec.json_key), spec.default) { + (Some(v), _) => v, + (None, Some(d)) => format!("{d} (default)"), + (None, None) if spec.cli_key == "output-format" => { + format!("{MP3_FORMAT} (default, follows the player)") + } + (None, None) => match resolve_player(None, None) { + Ok(p) => format!("{} (auto-detected)", p.program), + Err(_) => "none found on PATH".to_string(), + }, + } +} + +fn handle_config(args: ConfigArgs, _ctx: &AppContext) -> Result<(), CliError> { + let Some(key) = args.key.as_deref() else { + for spec in SETTINGS { + println!("{:<7} {}", format!("{}:", spec.label), describe(spec)); + } + println!( + "\nSet one with 'elevenlabs say config <{}> '.", + SETTINGS + .iter() + .map(|s| s.cli_key) + .collect::>() + .join("|") + ); + return Ok(()); + }; + + let spec = spec_for(key).ok_or_else(|| { + CliError::Validation(format!( + "Unknown setting '{key}'. Available: {}.", + SETTINGS + .iter() + .map(|s| s.cli_key) + .collect::>() + .join(", ") + )) + })?; + + if args.unset { + settings::write_setting(&[SECTION, spec.json_key], None)?; + println!("{} cleared. Now: {}", spec.label, describe(spec)); + return Ok(()); + } + + let Some(value) = args.value.as_deref() else { + println!("{:<7} {}", format!("{}:", spec.label), describe(spec)); + return Ok(()); + }; + + validate_value(spec, value)?; + let value = value.trim(); + settings::write_setting(&[SECTION, spec.json_key], Some(value))?; + println!("{} set to: {value}", spec.label); + Ok(()) +} + +// ── Registration ──────────────────────────────────────────────────── + +/// The `say` command, with `config` as a subcommand of it. +/// +/// Built as one `clap::Command` rather than two registrations because +/// `custom_commands::graft_subcommand` is custom-wins on leaf collision: +/// registering `say` at the root would *replace* the parent that grafting +/// `config` under `["say"]` had just created, silently dropping the +/// subcommand. Registering the pair as a single command also removes the +/// dispatch hazard — `walk_matches_to_custom(matches, &[], "say")` matches +/// every `say …` invocation, `say config` included. +fn say_command() -> clap::Command { + let config = ConfigArgs::augment_args( + clap::Command::new("config") + .about("Show or set the default voice, model, format and player for 'say'"), + ); + SayArgs::augment_args( + clap::Command::new("say") + .about("Speak text out loud using ElevenLabs text-to-speech") + .long_about( + "Convert text to speech and play it immediately.\n\n\ + The voice, model, audio format and player default to whatever \ + 'elevenlabs say config' has stored, and can be overridden per run.\n\n\ + Because 'config' is a subcommand, speaking that exact word needs \ + 'elevenlabs say -- config'.", + ), + ) + .subcommand(config) +} + +fn dispatch(matches: &clap::ArgMatches, ctx: &AppContext) -> Result<(), CliError> { + let to_validation = |e: clap::Error| CliError::Validation(e.to_string()); + match matches.subcommand() { + Some(("config", sub)) => handle_config( + ConfigArgs::from_arg_matches(sub).map_err(to_validation)?, + ctx, + ), + _ => handle_say( + SayArgs::from_arg_matches(matches).map_err(to_validation)?, + ctx, + ), + } +} + +/// Register `say` (and `say config`). +pub fn register(app: CliApp) -> CliApp { + app.command( + say_command(), + Box::new(|matches, ctx| dispatch(matches, super::util::downcast_ctx(ctx)?)), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── resolve ───────────────────────────────────────────────────── + + #[test] + fn a_flag_beats_the_config_which_beats_the_default() { + assert_eq!(resolve(Some("flag"), Some("config"), "default"), "flag"); + assert_eq!(resolve(None, Some("config"), "default"), "config"); + assert_eq!(resolve(None, None, "default"), "default"); + } + + #[test] + fn a_whitespace_only_value_counts_as_unset() { + // An empty voice ID would build `/v1/text-to-speech//stream`. + assert_eq!(resolve(Some(" "), Some("config"), "default"), "config"); + assert_eq!(resolve(Some(" "), Some(""), "default"), "default"); + } + + // ── formats ───────────────────────────────────────────────────── + + #[test] + fn known_formats_parse_to_their_codec() { + assert_eq!(codec_of("mp3_44100_128"), Some("mp3")); + assert_eq!(codec_of("wav_44100"), Some("wav")); + assert_eq!(codec_of("pcm_24000"), Some("pcm")); + assert_eq!(codec_of("opus_48000_128"), Some("opus")); + assert_eq!(codec_of("ulaw_8000"), Some("ulaw")); + assert_eq!(codec_of("alaw_8000"), Some("alaw")); + } + + #[test] + fn a_format_the_api_adds_later_is_still_accepted() { + // Deliberately not pinned to the generated enum: a new bitrate must + // not require a regeneration before it can be used. + assert_eq!(codec_of("mp3_48000_256"), Some("mp3")); + } + + #[test] + fn typos_and_junk_are_rejected() { + for bad in [ + "mp3", + "mp3_", + "_44100", + "mpe3_44100_128", + "wav_abc", + "", + "mp3_44100_", + "; rm -rf /", + ] { + assert!(codec_of(bad).is_none(), "{bad:?} should be rejected"); + } + } + + #[test] + fn an_explicit_format_wins_over_everything() { + assert_eq!( + output_format_for( + Some("opus_48000_64"), + Some("wav_44100"), + Some(Path::new("x.mp3")), + Container::WavOnly + ), + "opus_48000_64" + ); + } + + #[test] + fn an_output_filename_beats_the_stored_default() { + // Writing out.wav and getting MP3 bytes inside it is worse than + // ignoring the configured format. + assert_eq!( + output_format_for( + None, + Some("mp3_44100_128"), + Some(Path::new("out.wav")), + Container::Any + ), + WAV_FORMAT + ); + } + + #[test] + fn the_stored_default_applies_when_nothing_else_decides() { + assert_eq!( + output_format_for(None, Some("opus_48000_96"), None, Container::Any), + "opus_48000_96" + ); + } + + #[test] + fn a_wav_only_player_gets_wav() { + assert_eq!( + output_format_for(None, None, None, Container::WavOnly), + WAV_FORMAT + ); + assert_eq!( + output_format_for(None, None, None, Container::Any), + MP3_FORMAT + ); + } + + #[test] + fn an_unrecognized_extension_does_not_decide_the_format() { + assert_eq!( + output_format_for(None, None, Some(Path::new("out.bin")), Container::Any), + MP3_FORMAT + ); + } + + #[test] + fn extensions_match_the_codec_so_players_pick_a_decoder() { + assert_eq!(extension_for("mp3_44100_128"), "mp3"); + assert_eq!(extension_for("wav_44100"), "wav"); + assert_eq!(extension_for("opus_48000_128"), "opus"); + assert_eq!(extension_for("pcm_24000"), "raw"); + } + + // ── players ───────────────────────────────────────────────────── + + #[test] + fn argv_never_goes_through_a_shell() { + let ffplay = Player::from_spec(spec_for_program("ffplay").unwrap()); + assert_eq!( + player_argv(&ffplay, "-"), + vec!["-nodisp", "-autoexit", "-loglevel", "error", "-i", "-"] + ); + + let afplay = Player::from_spec(spec_for_program("afplay").unwrap()); + assert_eq!(player_argv(&afplay, "/tmp/a b.mp3"), vec!["/tmp/a b.mp3"]); + } + + #[test] + fn powershell_gets_a_single_quoted_script_with_quotes_doubled() { + let ps = Player::from_spec(spec_for_program("powershell").unwrap()); + let argv = player_argv(&ps, "C:\\tmp\\it's.wav"); + assert_eq!(argv[0], "-NoProfile"); + assert_eq!(argv[1], "-Command"); + assert_eq!( + argv[2], + "(New-Object Media.SoundPlayer 'C:\\tmp\\it''s.wav').PlaySync()" + ); + } + + #[test] + fn an_unknown_player_is_driven_conservatively() { + let p = Player::unknown("my-wrapper"); + assert!( + !p.reads_stdin, + "cannot assume an unknown player reads stdin" + ); + assert_eq!(player_argv(&p, "/tmp/a.mp3"), vec!["/tmp/a.mp3"]); + } + + #[test] + fn stdin_players_are_preferred_so_streaming_pays_off() { + let first_stdin = PLAYERS.iter().position(|p| p.reads_stdin).unwrap(); + let first_file = PLAYERS.iter().position(|p| !p.reads_stdin).unwrap(); + assert!(first_stdin < first_file); + } + + #[test] + fn a_missing_player_is_never_silently_accepted() { + let err = resolve_player(Some("definitely-not-a-real-player-xyz"), None).unwrap_err(); + assert!(matches!(err, CliError::Validation(_))); + } + + #[test] + fn nothing_on_an_empty_path_resolves() { + assert!(!on_path("ffplay-that-does-not-exist")); + assert!(!on_path("")); + } + + // ── text input ────────────────────────────────────────────────── + + #[test] + fn positional_words_are_joined() { + let args = ["this", "came", "from", "the", "terminal"].map(String::from); + assert_eq!( + text_source(&args, true).unwrap(), + TextSource::Literal("this came from the terminal".to_string()) + ); + } + + #[test] + fn a_lone_dash_reads_stdin() { + assert_eq!( + text_source(&[String::from("-")], true).unwrap(), + TextSource::Stdin + ); + } + + #[test] + fn piped_input_needs_no_argument() { + assert_eq!(text_source(&[], false).unwrap(), TextSource::Stdin); + } + + #[test] + fn an_interactive_terminal_with_no_text_is_an_error() { + assert!(matches!( + text_source(&[], true), + Err(CliError::Validation(_)) + )); + } + + #[test] + fn a_dash_among_other_words_is_just_text() { + let args = ["wait", "-", "then", "go"].map(String::from); + assert!(matches!( + text_source(&args, true).unwrap(), + TextSource::Literal(_) + )); + } + + // ── settings ──────────────────────────────────────────────────── + + #[test] + fn every_setting_is_addressable_by_the_key_users_type() { + for spec in SETTINGS { + assert!(spec_for(spec.cli_key).is_some()); + } + assert!(spec_for("nonsense").is_none()); + } + + #[test] + fn values_with_control_characters_are_rejected() { + // These get echoed back to a terminal, and a player name is executed. + let voice = spec_for("voice").unwrap(); + assert!(validate_value(voice, "ok\u{1b}[31m").is_err()); + assert!(validate_value(voice, "ok\nnot ok").is_err()); + assert!(validate_value(voice, "21m00Tcm4TlvDq8ikWAM").is_ok()); + } + + #[test] + fn an_id_with_whitespace_is_rejected() { + assert!(validate_value(spec_for("model").unwrap(), "eleven flash").is_err()); + assert!(validate_value(spec_for("voice").unwrap(), "").is_err()); + } + + #[test] + fn a_bad_format_is_rejected_at_set_time() { + let fmt = spec_for("output-format").unwrap(); + assert!(validate_value(fmt, "mp3").is_err()); + assert!(validate_value(fmt, "mp3_44100_128").is_ok()); + } + + // ── command wiring ────────────────────────────────────────────── + + /// `config` is a subcommand of `say`, so it always wins over a + /// positional of the same name; `--` is the documented escape hatch. + #[test] + fn saying_the_word_config_requires_a_double_dash() { + let cmd = SayArgs::augment_args( + clap::Command::new("say").subcommand(clap::Command::new("config")), + ); + + let matches = cmd + .clone() + .try_get_matches_from(["say", "config"]) + .expect("parses"); + assert_eq!(matches.subcommand_name(), Some("config")); + + let matches = cmd + .try_get_matches_from(["say", "--", "config"]) + .expect("parses"); + assert_eq!(matches.subcommand_name(), None); + assert_eq!( + matches + .get_many::("text") + .unwrap() + .cloned() + .collect::>(), + vec!["config"] + ); + } + + #[test] + fn flags_can_follow_the_text() { + let cmd = SayArgs::augment_args(clap::Command::new("say")); + let matches = cmd + .try_get_matches_from(["say", "hello", "there", "--voice", "abc"]) + .expect("parses"); + assert_eq!(matches.get_one::("voice").unwrap(), "abc"); + assert_eq!( + matches + .get_many::("text") + .unwrap() + .cloned() + .collect::>(), + vec!["hello", "there"] + ); + } +} diff --git a/cli/elevenlabs/workflow/settings.rs b/cli/elevenlabs/workflow/settings.rs index 73cfb88..81cd230 100644 --- a/cli/elevenlabs/workflow/settings.rs +++ b/cli/elevenlabs/workflow/settings.rs @@ -1,6 +1,6 @@ -//! User-level settings stored in `~/.elevenlabs/config.json` — currently -//! just data residency. Ports the residency half of v0's -//! `src/shared/config.ts`. +//! User-level settings stored in `~/.elevenlabs/config.json` — data +//! residency plus the `say` command's defaults. Ports the residency half +//! of v0's `src/shared/config.ts`. //! //! API-key storage is deliberately NOT handled here: v1 delegates //! credentials to the framework's keyring/env (`ELEVENLABS_API_KEY`), so @@ -32,24 +32,28 @@ fn config_file() -> Option { config_dir().map(|dir| dir.join("config.json")) } -/// Read the configured residency, defaulting to `global` on any problem -/// (missing file, unreadable, malformed) — matching v0's lenient default. -pub fn read_residency() -> String { - let Some(path) = config_file() else { - return DEFAULT_RESIDENCY.to_string(); - }; - let Ok(data) = std::fs::read_to_string(&path) else { - return DEFAULT_RESIDENCY.to_string(); - }; - serde_json::from_str::(&data) - .ok() - .and_then(|v| v.get("residency").and_then(Value::as_str).map(String::from)) - .unwrap_or_else(|| DEFAULT_RESIDENCY.to_string()) +// ── Generic get/set ───────────────────────────────────────────────── + +/// Read a nested string setting, e.g. `["say", "voice_id"]`. +/// +/// Returns `None` on any problem — missing file, unreadable, malformed, +/// missing key, or a non-string value. Callers supply their own default, +/// matching v0's lenient posture: a hand-mangled config degrades to +/// defaults rather than breaking every command. +pub fn read_setting(path: &[&str]) -> Option { + let file = config_file()?; + let data = std::fs::read_to_string(&file).ok()?; + let root: Value = serde_json::from_str(&data).ok()?; + let mut cursor = &root; + for key in path { + cursor = cursor.get(key)?; + } + cursor.as_str().map(String::from) } -/// Persist the residency, preserving any other keys and never writing an -/// API key into the config file (mirrors v0's `saveConfig`). -pub fn write_residency(residency: &str) -> Result<(), CliError> { +/// Persist a nested string setting, creating intermediate objects as +/// needed. `None` removes the key. +pub fn write_setting(path: &[&str], value: Option<&str>) -> Result<(), CliError> { let dir = config_dir() .ok_or_else(|| CliError::Other(anyhow::anyhow!("Could not determine home directory")))?; std::fs::create_dir_all(&dir) @@ -62,29 +66,72 @@ pub fn write_residency(residency: &str) -> Result<(), CliError> { use std::os::unix::fs::PermissionsExt; let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)); } - let path = dir.join("config.json"); + let file = dir.join("config.json"); - let existing = std::fs::read_to_string(&path) + let existing = std::fs::read_to_string(&file) .ok() .and_then(|d| serde_json::from_str::(&d).ok()); - super::project::write_json(&path, &merge_residency(existing, residency)) + super::project::write_json(&file, &merge_setting(existing, path, value)) } -/// Set `residency` on the existing config, preserving unrelated keys and -/// dropping any `api_key` — credentials belong in the framework's keyring, never -/// in this file. Split out from [`write_residency`] so it's testable without -/// touching a real home directory. -fn merge_residency(existing: Option, residency: &str) -> Value { - let mut obj = existing +/// Apply one setting to the existing config, preserving unrelated keys and +/// dropping any `api_key` — credentials belong in the framework's keyring, +/// never in this file. Split out from [`write_setting`] so the precedence +/// and stripping rules are testable without touching a real home directory. +fn merge_setting(existing: Option, path: &[&str], value: Option<&str>) -> Value { + let mut root = existing .and_then(|v| v.as_object().cloned()) .unwrap_or_default(); - obj.insert( - "residency".to_string(), - Value::String(residency.to_string()), - ); - obj.remove("api_key"); - Value::Object(obj) + root.remove("api_key"); + set_in(&mut root, path, value); + Value::Object(root) +} + +fn set_in(obj: &mut serde_json::Map, path: &[&str], value: Option<&str>) { + match path { + [] => {} + [leaf] => match value { + Some(v) => { + obj.insert((*leaf).to_string(), Value::String(v.to_string())); + } + None => { + obj.remove(*leaf); + } + }, + [head, rest @ ..] => { + let entry = obj + .entry((*head).to_string()) + .or_insert_with(|| Value::Object(Default::default())); + // A non-object here means the file was hand-edited to something + // else; replace it rather than silently dropping the write. + if !entry.is_object() { + *entry = Value::Object(Default::default()); + } + let nested = entry + .as_object_mut() + .expect("entry was just coerced to an object"); + set_in(nested, rest, value); + // Unsetting the last key in a section leaves no empty debris. + if nested.is_empty() { + obj.remove(*head); + } + } + } +} + +// ── Residency ─────────────────────────────────────────────────────── + +/// Read the configured residency, defaulting to `global` on any problem +/// (missing file, unreadable, malformed) — matching v0's lenient default. +pub fn read_residency() -> String { + read_setting(&["residency"]).unwrap_or_else(|| DEFAULT_RESIDENCY.to_string()) +} + +/// Persist the residency, preserving any other keys and never writing an +/// API key into the config file (mirrors v0's `saveConfig`). +pub fn write_residency(residency: &str) -> Result<(), CliError> { + write_setting(&["residency"], Some(residency)) } /// Map a residency to its API base URL. Ports v0's `getApiBaseUrl`. @@ -140,7 +187,7 @@ mod tests { #[test] fn merging_preserves_unrelated_keys() { let existing = json!({ "residency": "us", "other": 1 }); - let merged = merge_residency(Some(existing), "eu-residency"); + let merged = merge_setting(Some(existing), &["residency"], Some("eu-residency")); assert_eq!(merged["residency"], json!("eu-residency")); assert_eq!(merged["other"], json!(1)); } @@ -148,7 +195,7 @@ mod tests { #[test] fn merging_never_persists_an_api_key() { let existing = json!({ "api_key": "sk-secret", "residency": "us" }); - let merged = merge_residency(Some(existing), "global"); + let merged = merge_setting(Some(existing), &["residency"], Some("global")); assert!( merged.get("api_key").is_none(), "api_key must never be written to the config file" @@ -157,11 +204,55 @@ mod tests { #[test] fn merging_handles_a_missing_or_malformed_config() { - assert_eq!(merge_residency(None, "us")["residency"], json!("us")); + assert_eq!( + merge_setting(None, &["residency"], Some("us"))["residency"], + json!("us") + ); // A non-object config (e.g. hand-edited to a list) is replaced, not crashed on. assert_eq!( - merge_residency(Some(json!([1, 2])), "us")["residency"], + merge_setting(Some(json!([1, 2])), &["residency"], Some("us"))["residency"], json!("us") ); } + + #[test] + fn a_nested_setting_does_not_disturb_its_siblings() { + let existing = json!({ "residency": "us", "say": { "model_id": "m" } }); + let merged = merge_setting(Some(existing), &["say", "voice_id"], Some("v")); + assert_eq!(merged["residency"], json!("us")); + assert_eq!(merged["say"]["model_id"], json!("m")); + assert_eq!(merged["say"]["voice_id"], json!("v")); + } + + #[test] + fn a_nested_setting_creates_its_section() { + let merged = merge_setting(None, &["say", "voice_id"], Some("v")); + assert_eq!(merged["say"]["voice_id"], json!("v")); + } + + #[test] + fn a_nested_section_that_is_not_an_object_is_replaced() { + let existing = json!({ "say": "nonsense" }); + let merged = merge_setting(Some(existing), &["say", "voice_id"], Some("v")); + assert_eq!(merged["say"]["voice_id"], json!("v")); + } + + #[test] + fn unsetting_removes_the_key_and_prunes_an_empty_section() { + let existing = json!({ "residency": "us", "say": { "voice_id": "v" } }); + let merged = merge_setting(Some(existing), &["say", "voice_id"], None); + assert_eq!(merged["residency"], json!("us")); + assert!( + merged.get("say").is_none(), + "an emptied section should not linger in the file" + ); + } + + #[test] + fn unsetting_keeps_a_section_that_still_has_other_keys() { + let existing = json!({ "say": { "voice_id": "v", "model_id": "m" } }); + let merged = merge_setting(Some(existing), &["say", "voice_id"], None); + assert_eq!(merged["say"]["model_id"], json!("m")); + assert!(merged["say"].get("voice_id").is_none()); + } } diff --git a/tests/say_test.rs b/tests/say_test.rs new file mode 100644 index 0000000..30a39b5 --- /dev/null +++ b/tests/say_test.rs @@ -0,0 +1,273 @@ +//! Integration tests for `elevenlabs say`, run against a local mock server. +//! +//! These cover the one thing the inline unit tests in +//! `cli/elevenlabs/workflow/say.rs` cannot: that the audio player is spawned +//! only after the HTTP status has been checked. The SDK's +//! `text_to_speech.stream()` returns a `ByteStream` that discards the status, +//! and `CliExecutor` does not reject non-2xx on the executor path — so the +//! obvious implementation pipes a JSON error body into the user's audio +//! player. `say` sends and validates first, then spawns; if anyone +//! "simplifies" that apart again, `a_failed_request_never_reaches_the_player` +//! is what notices. +//! +//! No network: `wiremock` binds localhost, and `HOME` is redirected at a temp +//! dir so a developer's own `~/.elevenlabs/config.json` cannot change what the +//! CLI asks for. + +use std::path::Path; +use std::process::{Command, Output}; + +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +/// A recognizable WAV-ish payload. Only the bytes matter here — nothing +/// decodes it. +const AUDIO: &[u8] = b"RIFF\x24\x00\x00\x00WAVEfmt ELEVENLABS-SAY-TEST-PAYLOAD"; + +const VOICE_ID: &str = "JBFqnCBsd6RMkjVDRZzb"; + +/// Run the CLI with `HOME` redirected at a temp dir, so it cannot read or +/// write the developer's real `~/.elevenlabs/config.json`. +fn cli(home: &Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_elevenlabs")) + .args(args) + .env("HOME", home) + .env("ELEVENLABS_API_KEY", "test-key") + .env("NO_COLOR", "1") + // The OS keyring is not reachable (or not unlocked) on CI runners, and + // probing it can block; the file store needs no user interaction. + .env("FERN_CLI_CREDENTIAL_STORE", "file") + .env_remove("ELEVENLABS_BASE_URL") + .env_remove("ELEVENLABS_VIA") + .output() + .expect("failed to spawn the elevenlabs binary") +} + +/// Write a stand-in player that records the fact it ran. A real player needs +/// an audio device, which CI does not have. +#[cfg(unix)] +fn fake_player(dir: &Path, marker: &Path) -> std::path::PathBuf { + use std::os::unix::fs::PermissionsExt; + + let script = dir.join("fake-player"); + std::fs::write( + &script, + format!( + "#!/bin/sh\n# Consume stdin so a streaming caller is not left blocked.\ncat > /dev/null 2>&1\nprintf 'played' > '{}'\n", + marker.display() + ), + ) + .expect("write fake player"); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)) + .expect("chmod fake player"); + script +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread")] +async fn a_failed_request_never_reaches_the_player() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!("/v1/text-to-speech/{VOICE_ID}/stream"))) + .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({ + "detail": { "status": "invalid_api_key", "message": "Invalid API key" } + }))) + .mount(&server) + .await; + + let home = tempfile::tempdir().expect("tempdir"); + let marker = home.path().join("played.marker"); + let player = fake_player(home.path(), &marker); + let out_file = home.path().join("should-not-exist.mp3"); + + let out = cli( + home.path(), + &[ + "say", + "hello", + "--base-url", + &server.uri(), + "--player", + player.to_str().unwrap(), + ], + ); + + assert_eq!( + out.status.code(), + Some(1), + "a 401 should exit with the API error code, got {:?}\n{}", + out.status.code(), + String::from_utf8_lossy(&out.stderr), + ); + // The framework owns which stream the error envelope lands on; assert on + // the content, not the plumbing. + let reported = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + assert!( + reported.contains("Invalid API key") && reported.contains("401"), + "the API's own message should survive, got: {reported}" + ); + assert!( + !marker.exists(), + "the player must not run for a failed request — an error body would be \ + handed to it as if it were audio" + ); + + // The same guarantee for --output: no half-written file of JSON. + let out = cli( + home.path(), + &[ + "say", + "hello", + "--base-url", + &server.uri(), + "--output", + out_file.to_str().unwrap(), + ], + ); + assert_eq!(out.status.code(), Some(1)); + assert!( + !out_file.exists(), + "a failed request must not leave a file behind" + ); +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread")] +async fn a_successful_request_streams_the_audio_to_the_player() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!("/v1/text-to-speech/{VOICE_ID}/stream"))) + .respond_with(ResponseTemplate::new(200).set_body_bytes(AUDIO)) + .mount(&server) + .await; + + let home = tempfile::tempdir().expect("tempdir"); + let marker = home.path().join("played.marker"); + let player = fake_player(home.path(), &marker); + + let out = cli( + home.path(), + &[ + "say", + "this came from the terminal", + "--base-url", + &server.uri(), + "--player", + player.to_str().unwrap(), + ], + ); + + assert!( + out.status.success(), + "say failed (exit {:?}): {}", + out.status.code(), + String::from_utf8_lossy(&out.stderr), + ); + assert!(marker.exists(), "the player should have run"); +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread")] +async fn the_response_body_is_written_verbatim_to_output() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!("/v1/text-to-speech/{VOICE_ID}/stream"))) + .respond_with(ResponseTemplate::new(200).set_body_bytes(AUDIO)) + .mount(&server) + .await; + + let home = tempfile::tempdir().expect("tempdir"); + let out_file = home.path().join("out.mp3"); + + let out = cli( + home.path(), + &[ + "say", + "hello", + "--base-url", + &server.uri(), + "--output", + out_file.to_str().unwrap(), + ], + ); + + assert!( + out.status.success(), + "say --output failed: {}", + String::from_utf8_lossy(&out.stderr), + ); + assert_eq!( + std::fs::read(&out_file).expect("read output"), + AUDIO, + "every chunk should reach the file unmodified" + ); +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread")] +async fn stored_defaults_drive_the_request() { + let server = MockServer::start().await; + let custom_voice = "21m00Tcm4TlvDq8ikWAM"; + Mock::given(method("POST")) + .and(path(format!("/v1/text-to-speech/{custom_voice}/stream"))) + .respond_with(ResponseTemplate::new(200).set_body_bytes(AUDIO)) + .mount(&server) + .await; + + let home = tempfile::tempdir().expect("tempdir"); + let out_file = home.path().join("out.wav"); + + assert!(cli(home.path(), &["say", "config", "voice", custom_voice]) + .status + .success()); + assert!(cli( + home.path(), + &["say", "config", "model", "eleven_multilingual_v2"] + ) + .status + .success()); + + // The voice only reaches the URL if the stored default was read, so a + // matched mock is the assertion. + let out = cli( + home.path(), + &[ + "say", + "hello", + "--base-url", + &server.uri(), + "--output", + out_file.to_str().unwrap(), + ], + ); + assert!( + out.status.success(), + "the stored voice should have been used: {}", + String::from_utf8_lossy(&out.stderr), + ); + + let requests = server.received_requests().await.expect("recorded requests"); + let body: serde_json::Value = + serde_json::from_slice(&requests[0].body).expect("request body is JSON"); + assert_eq!(body["model_id"], "eleven_multilingual_v2"); + assert_eq!(body["text"], "hello"); + + // `say config` must never disturb the residency key it shares a file with. + assert!(cli(home.path(), &["residency", "eu-residency"]) + .status + .success()); + assert!(cli(home.path(), &["say", "config", "voice", "--unset"]) + .status + .success()); + let config: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(home.path().join(".elevenlabs/config.json")).expect("read config"), + ) + .expect("config is JSON"); + assert_eq!(config["residency"], "eu-residency"); + assert_eq!(config["say"]["model_id"], "eleven_multilingual_v2"); + assert!(config["say"].get("voice_id").is_none()); +} From 6680150ca488c2e0325466678c80173502aba503 Mon Sep 17 00:00:00 2001 From: Paul Asjes Date: Mon, 7 Sep 2026 09:34:26 +0200 Subject: [PATCH 2/3] Add skill for say --- .agents/skills/say/SKILL.md | 1 + .fernignore | 8 + cli/elevenlabs/workflow/mod.rs | 2 + cli/elevenlabs/workflow/skills.rs | 201 ++++++++++++++++++++++++++ cli/elevenlabs/workflow/skills/say.md | 144 ++++++++++++++++++ 5 files changed, 356 insertions(+) create mode 120000 .agents/skills/say/SKILL.md create mode 100644 cli/elevenlabs/workflow/skills.rs create mode 100644 cli/elevenlabs/workflow/skills/say.md diff --git a/.agents/skills/say/SKILL.md b/.agents/skills/say/SKILL.md new file mode 120000 index 0000000..7c03770 --- /dev/null +++ b/.agents/skills/say/SKILL.md @@ -0,0 +1 @@ +../../../cli/elevenlabs/workflow/skills/say.md \ No newline at end of file diff --git a/.fernignore b/.fernignore index 1065da9..09f8c03 100644 --- a/.fernignore +++ b/.fernignore @@ -5,6 +5,14 @@ cli/elevenlabs/custom.rs cli/elevenlabs/workflow/ +# Hand-written agent skills. The real files live under +# cli/elevenlabs/workflow/skills/ (protected above, because `generate-skills` +# embeds them with include_str!); these are the copies an agent harness +# actually scans. `.claude` is a symlink to `.agents`, so both are listed — +# losing either one loses the skills for whichever path the harness uses. +.agents/ +.claude + # Hand-maintained README + assets (Fern generates a default README; # we own it to document the agents-as-code workflow and hero image). README.md diff --git a/cli/elevenlabs/workflow/mod.rs b/cli/elevenlabs/workflow/mod.rs index b4671e9..9f096fc 100644 --- a/cli/elevenlabs/workflow/mod.rs +++ b/cli/elevenlabs/workflow/mod.rs @@ -20,6 +20,7 @@ mod project; mod residency; mod say; mod settings; +mod skills; mod templates; mod tests; mod tools; @@ -34,5 +35,6 @@ pub fn register(app: CliApp) -> CliApp { let app = tests::register(app); let app = residency::register(app); let app = say::register(app); + let app = skills::register(app); components::register(app) } diff --git a/cli/elevenlabs/workflow/skills.rs b/cli/elevenlabs/workflow/skills.rs new file mode 100644 index 0000000..7ee3fad --- /dev/null +++ b/cli/elevenlabs/workflow/skills.rs @@ -0,0 +1,201 @@ +//! `generate-skills`, extended to cover the hand-written commands. +//! +//! The framework's own `generate-skills` renders one `SKILL.md` per OpenAPI +//! resource group, driven entirely by the embedded spec — so the commands in +//! this `workflow/` tree, which exist only here, are invisible to it. An +//! agent that installed the generated skills would have no idea `say`, +//! `agents push` or `residency` exist. +//! +//! This shadows the built-in rather than duplicating it: `graft_subcommand` +//! is custom-wins on leaf collision and custom commands are dispatched ahead +//! of binding operations, so registering `generate-skills` here takes over +//! the name. Every spec-derived file still comes from the framework's own +//! emitter via [`skill_emitter::generate_skills`], so improvements to it +//! keep flowing through; this only appends the hand-written skills and +//! writes the result. +//! +//! Adding a skill: drop a `.md` in `skills/` next to this file and add +//! it to [`CUSTOM_SKILLS`]. It lives here, rather than under `.agents/skills/` +//! with the rest, because `.fernignore` protects `cli/elevenlabs/workflow/` +//! but not `.agents/` — a regeneration that swept `.agents/` away would take +//! the `include_str!` target with it and break the build. `.agents/skills/say/ +//! SKILL.md` is a symlink back to this copy, so an agent working in this repo +//! still loads it (`.claude` symlinks to `.agents`) without a second file to +//! keep in sync. +//! +//! Those same bytes serve both audiences, so a skill must not contain paths +//! relative to either layout. + +use std::path::{Path, PathBuf}; + +use fern_cli_sdk::app::CliApp; +use fern_cli_sdk::auth::{no_auth_provider, SchemeBinding}; +use fern_cli_sdk::error::CliError; +use fern_cli_sdk::openapi::{skill_emitter, AppContext}; + +/// The binary name, which `main.rs` pins via `CliApp::new("elevenlabs")`. +/// The emitter takes it as a parameter to prefix every skill directory +/// (`elevenlabs-shared`, `elevenlabs-agents`, …); `AppContext` does not +/// expose it, and this file only ever ships in the elevenlabs CLI. +const BIN_NAME: &str = "elevenlabs"; + +/// Hand-written skills, as `(directory suffix, contents)`. +/// +/// `include_str!` rather than a runtime read: the generated skills have to +/// work from an installed binary, which has no repo to read from. +const CUSTOM_SKILLS: &[(&str, &str)] = &[("say", include_str!("skills/say.md"))]; + +/// The auth bindings to render the shared skill's "Authentication" section +/// from. +/// +/// `AppContext` carries the resolved auth *provider* but not the bindings +/// that describe it, and they live on a `pub(crate)` field of the framework's +/// `CliApp`, so there is nothing to forward. Passing an empty slice is not an +/// option: the emitter would fall back to the spec's `securitySchemes`, which +/// this API declares as `{}`, and the section would read "No authentication +/// configured." +/// +/// So it is reconstructed to match what `main.rs` declares — a PKCE login +/// flow named `OAuth`. The emitter renders a `Custom` binding as the constant +/// string "custom auth provider" and collects no environment variables from +/// it, so the provider handed over here is never consulted and the output is +/// identical to the built-in's. `the_shared_skill_still_documents_oauth` +/// fails loudly if a future emitter starts reading it. +fn auth_bindings() -> Vec<(String, SchemeBinding)> { + vec![( + "OAuth".to_string(), + SchemeBinding::Custom(no_auth_provider()), + )] +} + +/// Every file `generate-skills` should write, spec-derived ones first. +fn skill_files(ctx: &AppContext) -> Vec<(PathBuf, String)> { + let mut files = skill_emitter::generate_skills(ctx.spec(), BIN_NAME, &auth_bindings()); + files.extend(CUSTOM_SKILLS.iter().map(|(name, content)| { + ( + PathBuf::from(format!("{BIN_NAME}-{name}")).join("SKILL.md"), + (*content).to_string(), + ) + })); + files +} + +fn write_all(root: &Path, files: &[(PathBuf, String)]) -> Result<(), CliError> { + for (rel_path, content) in files { + let full_path = root.join(rel_path); + if let Some(parent) = full_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + CliError::Validation(format!( + "Failed to create directory {}: {e}", + parent.display() + )) + })?; + } + std::fs::write(&full_path, content).map_err(|e| { + CliError::Validation(format!("Failed to write {}: {e}", full_path.display())) + })?; + } + Ok(()) +} + +#[derive(clap::Args)] +struct SkillsArgs { + /// Output directory [default: skills] + #[arg(long, value_name = "PATH")] + output_dir: Option, +} + +fn handle(args: SkillsArgs, ctx: &AppContext) -> Result<(), CliError> { + let out_dir = args.output_dir.as_deref().unwrap_or("skills"); + // Same guard the framework applies: refuses control characters and paths + // that would escape the working tree. + let resolved = fern_cli_sdk::validate::validate_safe_output_dir(out_dir)?; + + let files = skill_files(ctx); + write_all(&resolved, &files)?; + + eprintln!( + "Wrote {} skill file(s) to {}/", + files.len(), + resolved.display() + ); + Ok(()) +} + +/// Register `generate-skills`, replacing the framework's own. +/// +/// The `about` text matches the built-in so `--help` reads the same whether +/// or not this override is present. +pub fn register(app: CliApp) -> CliApp { + app.command_typed_with( + clap::Command::new("generate-skills") + .about("Generate SKILL.md files for AI agent integration"), + handle, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every hand-written skill needs the frontmatter an agent harness reads + /// to decide whether to load it. + #[test] + fn custom_skills_carry_usable_frontmatter() { + for (name, content) in CUSTOM_SKILLS { + assert!( + content.starts_with("---\n"), + "{name}: SKILL.md must open with YAML frontmatter" + ); + let front = content + .split("---\n") + .nth(1) + .unwrap_or_else(|| panic!("{name}: unterminated frontmatter")); + assert!( + front.contains(&format!("name: {BIN_NAME}-{name}")), + "{name}: the frontmatter name must match the emitted directory \ + {BIN_NAME}-{name}, or the two copies drift" + ); + assert!( + front.contains("description:"), + "{name}: a skill without a description is never selected" + ); + } + } + + /// The same bytes are installed by `generate-skills` and read in-repo + /// from `.agents/skills/`, so a path that only resolves in one layout is + /// a broken link in the other. + #[test] + fn custom_skills_avoid_layout_relative_links() { + for (name, content) in CUSTOM_SKILLS { + assert!( + !content.contains("](../") && !content.contains("`../"), + "{name}: SKILL.md must not reference sibling skills by relative \ + path — the in-repo and generated layouts differ" + ); + } + } + + /// Guards the reconstruction in [`auth_bindings`]: if the emitter ever + /// starts reading the provider inside a `Custom` binding, the stand-in + /// stops being equivalent and this notices. + #[test] + fn the_shared_skill_still_documents_oauth() { + let doc = fern_cli_sdk::openapi::discovery::RestDescription::default(); + let files = skill_emitter::generate_skills(&doc, BIN_NAME, &auth_bindings()); + let (_, shared) = files + .iter() + .find(|(p, _)| p.starts_with(format!("{BIN_NAME}-shared"))) + .expect("the shared skill is always emitted"); + assert!( + shared.contains("- **OAuth** (bearer): custom auth provider"), + "the shared skill lost its authentication line; auth_bindings() \ + no longer reproduces what the framework renders:\n{shared}" + ); + assert!( + !shared.contains("No authentication configured"), + "an empty binding list leaked through" + ); + } +} diff --git a/cli/elevenlabs/workflow/skills/say.md b/cli/elevenlabs/workflow/skills/say.md new file mode 100644 index 0000000..fcbbec7 --- /dev/null +++ b/cli/elevenlabs/workflow/skills/say.md @@ -0,0 +1,144 @@ +--- +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, or set a default voice/model/player for the CLI. +--- + +# `elevenlabs say` + +Turns text into speech and plays it, without picking a voice ID, a file path +or a player. + +```bash +elevenlabs say "this came from the terminal" +``` + +Needs credentials: either `elevenlabs auth login` or `ELEVENLABS_API_KEY` in +the environment. + +## Reading the text + +Three equivalent sources — pick whichever suits the caller: + +```bash +elevenlabs say "hello there" # positional (quote it) +elevenlabs say hello there # bare words are joined with spaces +echo "build finished" | elevenlabs say # piped stdin +elevenlabs say - # explicit stdin +``` + +With no argument and no piped input, `say` errors instead of hanging. + +> `config` is a subcommand, so speaking that exact word needs +> `elevenlabs say -- config`. Put any flags **before** the `--`. + +## Defaults + +Stored per user in `~/.elevenlabs/config.json` under a `say` key, alongside +the `residency` setting. Nothing sensitive goes in that file — credentials +stay in the OS keyring. + +```bash +elevenlabs say config # show everything +elevenlabs say config voice 21m00Tcm4TlvDq8ikWAM +elevenlabs say config model eleven_multilingual_v2 +elevenlabs say config output-format wav_44100 +elevenlabs say config player mpv +elevenlabs say config voice # show just this one +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_flash_v2_5` | +| `output-format` | `say.output_format` | follows the player | +| `player` | `say.player` | first one found on `PATH` | + +`eleven_flash_v2_5` is the low-latency model — the right default for a +terminal one-liner. Switch to `eleven_multilingual_v2` when quality matters +more than the first-audio delay. + +Find voice IDs with `elevenlabs voices search`, and model IDs with +`elevenlabs models list`. + +## Per-run overrides + +Every default has a flag that wins for one invocation: + +```bash +elevenlabs say "one off" \ + --voice JBFqnCBsd6RMkjVDRZzb \ + --model eleven_multilingual_v2 \ + --output-format mp3_44100_192 \ + --player mpv +``` + +Precedence is always **flag > config file > built-in default**. + +## Saving instead of playing + +`--output` writes the audio and skips playback entirely, so it works on a +machine with no audio player and no sound device: + +```bash +elevenlabs say "saved to a file" --output out.mp3 +elevenlabs say "as wav" -o out.wav # extension picks the format +``` + +## Audio formats + +`output_format` is `codec_samplerate[_bitrate]`, e.g. `mp3_44100_128`, +`wav_44100`, `pcm_24000`, `opus_48000_128`, `ulaw_8000`. Which one gets used: + +1. `--output-format`, if given. +2. The `--output` filename's extension (`.mp3`, `.wav`, `.opus`/`.ogg`, + `.pcm`/`.raw`, `.ulaw`, `.alaw`) — a filename beats the stored default so + `out.wav` never ends up holding MP3 bytes. +3. The configured `say.output_format`. +4. The player's requirement: WAV for the WAV-only players below, else + `mp3_44100_128`. + +## Players + +Playback shells out; nothing is linked into the binary. The first of these +found on `PATH` wins, unless `--player` or `say config player` names one: + +| Player | Reads stdin | Accepts | Notes | +|---|---|---|---| +| `ffplay` | yes | any | from ffmpeg | +| `mpv` | yes | any | | +| `afplay` | no | any | macOS, always present | +| `paplay` | no | WAV only | Linux/PulseAudio | +| `aplay` | no | WAV only | Linux/ALSA | +| PowerShell `Media.SoundPlayer` | no | WAV only | Windows | + +stdin-capable players are preferred because audio starts on the first chunk +instead of after the whole download. The others get a temp file, played once +the stream finishes. + +`--player` also accepts a command that is not in that table (a wrapper +script, say). Unknown players are driven conservatively: the audio is written +to a temp file and the path is passed as the single argument. + +Arguments are always passed individually, never through a shell. + +## Troubleshooting + +**`No audio player found.`** — install ffmpeg or mpv, pass `--player`, or use +`--output ` to skip playback. + +**`Audio player 'x' was not found on PATH.`** — the configured or requested +player is missing. `elevenlabs say config player --unset` returns to +auto-detection. + +**`The 'aplay' player only handles WAV, but the format is 'mp3_44100_128'.`** +— either `--output-format wav_44100` or switch to a player that decodes MP3. + +**A 401/422 prints a JSON error envelope and exits non-zero.** The request is +sent and its status checked *before* any player is spawned or file created, +so a failed call never plays an error body or leaves a partial file behind. +Exit codes follow the CLI's convention: `1` for an API error, `3` for a +validation error. + +**Requests go to the wrong region.** `say` honors `elevenlabs residency`, +`ELEVENLABS_BASE_URL` and `--base-url` like every other command. From 1cc0e6fce6a0f60cafebe3f2118d4c8bb518234b Mon Sep 17 00:00:00 2001 From: Paul Asjes Date: Mon, 7 Sep 2026 10:08:56 +0200 Subject: [PATCH 3/3] Switch to v3 being the default --- README.md | 4 +-- cli/elevenlabs/workflow/say.rs | 9 ++++-- cli/elevenlabs/workflow/skills/say.md | 41 +++++++++++++++++++++++---- 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 11b940b..25a8cef 100644 --- a/README.md +++ b/README.md @@ -131,11 +131,11 @@ elevenlabs say config model eleven_multilingual_v2 elevenlabs say config player mpv elevenlabs say config voice --unset # back to the built-in default -elevenlabs say "one off" --voice JBFqnCBsd6RMkjVDRZzb --model eleven_flash_v2_5 +elevenlabs say "one off" --voice JBFqnCBsd6RMkjVDRZzb --model eleven_flash_v2_5 # faster, no audio tags 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_flash_v2_5` — the low-latency model — and 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_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. 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. diff --git a/cli/elevenlabs/workflow/say.rs b/cli/elevenlabs/workflow/say.rs index a491772..3b314b9 100644 --- a/cli/elevenlabs/workflow/say.rs +++ b/cli/elevenlabs/workflow/say.rs @@ -32,9 +32,12 @@ 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. const DEFAULT_VOICE_ID: &str = "JBFqnCBsd6RMkjVDRZzb"; -/// Latency beats fidelity for a terminal one-liner. `eleven_multilingual_v2` -/// is the quality swap: `elevenlabs say config model eleven_multilingual_v2`. -const DEFAULT_MODEL_ID: &str = "eleven_flash_v2_5"; +/// 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`. +const DEFAULT_MODEL_ID: &str = "eleven_v3"; const MP3_FORMAT: &str = "mp3_44100_128"; const WAV_FORMAT: &str = "wav_44100"; diff --git a/cli/elevenlabs/workflow/skills/say.md b/cli/elevenlabs/workflow/skills/say.md index fcbbec7..e98fb16 100644 --- a/cli/elevenlabs/workflow/skills/say.md +++ b/cli/elevenlabs/workflow/skills/say.md @@ -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, 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 v3 audio tags, or set a default voice/model/player for the CLI. --- # `elevenlabs say` @@ -50,13 +50,13 @@ 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_flash_v2_5` | +| `model` | `say.model_id` | `eleven_v3` | | `output-format` | `say.output_format` | follows the player | | `player` | `say.player` | first one found on `PATH` | -`eleven_flash_v2_5` is the low-latency model — the right default for a -terminal one-liner. Switch to `eleven_multilingual_v2` when quality matters -more than the first-audio delay. +`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. Find voice IDs with `elevenlabs voices search`, and model IDs with `elevenlabs models list`. @@ -75,6 +75,37 @@ elevenlabs say "one off" \ Precedence is always **flag > config file > built-in default**. +## Audio tags (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: + +```bash +elevenlabs say "[excited] The build passed!" +elevenlabs say "[whispers] don't tell anyone [laughs]" +elevenlabs say "[sarcastic] Oh, brilliant. [sighs]" +``` + +Three rough families: + +- **Delivery and emotion** — `[excited]`, `[curious]`, `[sarcastic]`, + `[whispers]`, `[mischievously]`, `[crying]` +- **Non-verbal sounds** — `[laughs]`, `[laughs harder]`, `[starts laughing]`, + `[sighs]`, `[exhales]`, `[snorts]` +- **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 +`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". + +How well a tag lands depends on the voice: one whose training fights the +direction (`[shout]` on a soft narrator) will ignore it, tags can be combined, +and the experimental ones are worth trying before you depend on them. + +More information: + ## Saving instead of playing `--output` writes the audio and skips playback entirely, so it works on a