Purpose
Render a v3-tagged script (typically produced by format-script-to-style) into an mp3 using ElevenLabs' eleven_v3 model, picking an approved voice from VOICES.md, then applying ffmpeg atempo post-process to hit the desired pace.
Why a v3-specific skill (vs. the existing create-voiceover-elevenlabs): v3 interprets audio tags expressively but silently ignores the speed voice_setting (see memory feedback_v3_ignores_speed.md). Pace control must be post-processed. This skill bakes that in.
Inputs
tagged_script: the v3-tagged script text (from the calling agent orformat-script-to-style).target_duration_seconds(optional): if provided, the skill computes the required atempo factor and applies it. If absent, no post-process is applied.- Voice selection: interactive — the skill presents the approved voices from
VOICES.mdand asks the user to choose. The chosen voice's storedv3 settingsare used as defaults. output_dir: where to save outputs. Defaults to<project>/working/voiceover/orskills/test-runs/<timestamp>/generate-voiceover-elevenlabs-v3/for test runs.
Workflow
Prefer the bundled runnable script when invoked programmatically:
python3 skills/atoms/voiceover/generate-voiceover-elevenlabs-v3/scripts/generate.py \
--voice-id <id> \
--script-file <tagged-script.md> \
--target-duration <seconds> \
--output-dir <dir> \
--name <basename>
The script handles env loading, the v3 API call, ffprobe duration measurement, atempo computation/clamping, and manifest writing. The interactive workflow below is what an agent should do when invoking the skill via chat (vs. the script-direct path):
- Load and present voices. Read
/Users/0xhbam/Desktop/Cursor/content-goose/VOICES.md. Show the list to the user and ask which to use. Extract the chosen voice'svoiceIdand thev3 settingsline if present. - Confirm the script. Show the tagged script to the user and confirm before any paid API call. (Per
feedback_media_pipeline_gates.md: show before render.) - Resolve the ElevenLabs API key. The key lives in
gtm-goose/.envasELEVEN_LABS_API_KEY(underscore). Alias toELEVENLABS_API_KEYfor the call:export ELEVENLABS_API_KEY=$(grep '^ELEVEN_LABS_API_KEY' /Users/0xhbam/Desktop/Cursor/gtm-goose/.env | sed 's/.*=[[:space:]]*//' | tr -d '"' | tr -d "'" | xargs) - Call ElevenLabs v3 TTS:
Usecurl -sS -X POST "https://api.elevenlabs.io/v1/text-to-speech/<VOICE_ID>?output_format=mp3_44100_128" \ -H "xi-api-key: $ELEVENLABS_API_KEY" \ -H "Content-Type: application/json" \ -d "$(python3 -c "import json,sys; print(json.dumps({'text': sys.argv[1], 'model_id': 'eleven_v3', 'voice_settings': <voice_settings_from_VOICES.md>}))" "$TAGGED_SCRIPT")" \ -o "<output_dir>/<name>.mp3"mp3_44100_128(higher formats are tier-gated; see learning from 2026-05-16 test). - Measure rendered duration:
ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "<name>.mp3" - Apply atempo post-process if
target_duration_secondswas supplied:- Compute
atempo_factor = rendered_duration / target_duration_seconds. - Clamp to
[1.0, 1.25]. Above 1.25 voice starts to sound chipmunky; surface to user instead of pushing further (alternative: trim script and re-render). - If
atempo_factor < 1.05, skip the post-process (negligible difference). - Apply:
ffmpeg -y -i "<name>.mp3" -filter:a "atempo=<factor>" -b:a 192k "<name>_atempo<factor>.mp3"
- Compute
- Write a manifest alongside the output with: voice_id, voice_name, model, voice_settings, tagged_script, rendered_duration, target_duration, atempo_factor (or
null), output_paths. - Report final duration vs. target to the user.
Output
<name>.mp3— raw v3 render<name>_atempo<factor>.mp3— atempo-corrected render (only iftarget_duration_secondswas supplied and factor > 1.05)<name>.manifest.json— generation parameters and final paths
Quality Checks
- Voice selected is present in
VOICES.md. - HTTP 200 from the ElevenLabs API; output mp3 is valid (use
fileandffprobeto confirm non-zero duration). - If
target_duration_secondssupplied: final mp3 duration is within ±10% of target. - atempo factor stayed within the clamp range — if it didn't, the skill stopped and surfaced.
- API key was loaded from
gtm-goose/.env(not hardcoded, not asked from the user).
Failure Modes
403 subscription_requiredon output_format — drop tomp3_44100_128. Higher formats are Creator-tier-gated.401 invalid_api_key— key was not loaded correctly. Re-check the env-var name (note: it'sELEVEN_LABS_API_KEYwith underscore in the source.env).- Voice id not in
VOICES.md— ask the user to confirm and offer to add it to VOICES.md withclone-voice-elevenlabsorsearch-voices-elevenlabs. - Rendered duration is much longer than target (atempo factor > 1.25 required) — stop and offer to trim the script or switch model to
eleven_multilingual_v2. - Audio tags appear unspoken in the rendered audio — the model didn't interpret them as tags. Likely cause: tag syntax error or tag stacking. Re-check the recipe.
- Source script contains 4-byte unicode (some emojis) that breaks JSON serialization — strip emojis from the script body before calling.