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
9 changes: 9 additions & 0 deletions recipes/audio/bash/native/voice-cloning/.env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,11 @@
# Get a key at https://platform.speechify.ai/api-keys
# Voice cloning must be enabled on your plan.
SPEECHIFY_API_KEY=

# Full name of the person consenting to have their voice cloned.
CONSENT_FULL_NAME=Jane Doe

# Optional — override where the recipe reads the audio from.
# Defaults to sample.wav and consent.wav in the recipe folder.
# SAMPLE_PATH=./sample.wav
# CONSENT_RECORDING_PATH=./consent.wav
51 changes: 35 additions & 16 deletions recipes/audio/bash/native/voice-cloning/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Text-to-Speech: voice cloning (Bash, native REST)

Clone a voice from an audio sample, synthesize speech with the clone, then delete it
— same lifecycle as the [TypeScript](../../../typescript/native/voice-cloning) and
Clone a voice from an audio sample, synthesize speech with the clone, then delete it, with
**verified consent** — same lifecycle as the
[TypeScript](../../../typescript/native/voice-cloning) and
[Python](../../../python/native/voice-cloning) native recipes, but as a self-contained
shell script using `curl` + `jq`.

Expand All @@ -12,6 +13,9 @@ shell script using `curl` + `jq`.
pointing to [Speechify pricing](https://speechify.ai/pricing) (the API returns
`402 voice_cloning_not_included`).
- `bash`, `curl`, `jq`, and `base64`
- **Two recordings of the same consenting person** (see [Consent](#consent)):
- a voice **sample** to clone — 10–30s of clean speech
- a **consent recording** — that person reading the challenge phrase this script prints

## Setup

Expand All @@ -22,28 +26,43 @@ chmod +x voice-cloning.sh

## Run

Because consent is verified against a phrase the API generates, this is a two-step run:

```bash
./voice-cloning.sh
./voice-cloning.sh # 1st run: prints the phrase to read, then exits
# record the speaker reading that phrase → consent.wav, and their voice → sample.wav
./voice-cloning.sh # 2nd run: clones, synthesizes, deletes
```

Produces an `output.mp3` spoken in the cloned voice, then removes the cloned voice.
Put `sample.wav` and `consent.wav` next to the script, or point `SAMPLE_PATH` /
`CONSENT_RECORDING_PATH` at them. Produces an `output.mp3` in the cloned voice, then
removes the cloned voice.

## What it does

- `POST /v1/voices/consent-challenges` (JSON `{ full_name }`, built with `jq -n`) — returns
a `phrase` the speaker must read aloud and an `id`; cached in `.consent-challenge.json`
between runs (single-use).
- `POST /v1/voices` (**multipart/form-data**) via `curl -F`: fields `name`, `gender`,
`consent`, and a `sample=@fixtures/spacewalk.wav` file part. `curl` sets the
`Content-Type` boundary automatically.
- `POST /v1/audio/speech` with the returned `voice_id`. `jq -n` builds the JSON body
safely.
- `DELETE /v1/voices/{id}` runs from an `EXIT` trap, so the cloned voice is removed
even if step 2 fails.
`consent_challenge_id`, and `sample=@…` + `consent_recording=@…` file parts. `curl` sets
the `Content-Type` boundary automatically.
- `POST /v1/audio/speech` with the returned `voice_id`.
- `DELETE /v1/voices/{id}` runs from an `EXIT` trap, so the cloned voice is removed even if
a later step fails.

All calls pin `Speechify-Version: 2026-09-13`, the API version the verified-consent flow
ships on.

## Consent

`consent` is a **required** JSON string attesting you have the speaker's permission to
clone their voice (`{"fullName": "...", "email": "..."}`). Only clone voices you are
authorized to. The bundled `fixtures/spacewalk.wav` is ~26s of NASA ISS spacewalk audio
(U.S. government work, public domain); replace it with your own consented sample for
real use.
Cloning requires **verified consent**. You create a consent challenge, the speaker records
themselves reading the returned `phrase`, and that recording is sent as `consent_recording`
and retained as the consent record. The `consent_recording` **must be the same person** as
the voice `sample` — so there is no shippable sample that comes with valid consent, and this
recipe is deliberately bring-your-own-audio. Only clone voices you are authorized to.

> Voice cloning reference: https://docs.speechify.ai/tts/guides/voice-cloning
> The old `consent` JSON field (`fullName` + `email`) is removed on `Speechify-Version:
> 2026-09-13` — see the
> [migration guide](https://docs.speechify.ai/build/guides/deprecations/migrating-voice-cloning-consent).
>
> Voice cloning reference: https://docs.speechify.ai/build/guides/voice-cloning/overview
Binary file not shown.
77 changes: 60 additions & 17 deletions recipes/audio/bash/native/voice-cloning/voice-cloning.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
set -euo pipefail

# Speechify TTS voice cloning (Bash + curl + jq).
# Full lifecycle: clone → use → delete.
# Full lifecycle: clone → use → delete, with verified consent.

cd "$(dirname "$0")"

Expand All @@ -16,24 +16,64 @@ fi
: "${SPEECHIFY_API_KEY:?Set SPEECHIFY_API_KEY (copy .env.example to .env).}"

BASE="https://api.speechify.ai"
# Bundled sample: ~26s of NASA ISS spacewalk audio (public domain).
SAMPLE="fixtures/spacewalk.wav"

# 1. Clone a voice from an audio sample (10-30s of clean speech works well).
# POST /v1/voices is multipart/form-data — `curl -F` builds the body and sets
# the Content-Type boundary automatically. `consent` is REQUIRED: a JSON
# string attesting you have the speaker's permission to clone their voice.
http_status=0
# The verified-consent flow ships on this API version — pin it explicitly.
VERSION="2026-09-13"

# Cloning requires VERIFIED consent: the speaker records themselves reading a
# phrase the API returns, and that recording is kept as the consent record. It
# must be the SAME person as the voice sample, so this recipe is
# bring-your-own-audio — there is no sample that ships with valid consent.
CONSENT_FULL_NAME="${CONSENT_FULL_NAME:-Jane Doe}"
SAMPLE="${SAMPLE_PATH:-sample.wav}"
CONSENT="${CONSENT_RECORDING_PATH:-consent.wav}"
# The challenge is single-use and its phrase is dynamic, so we cache it between
# runs: run once to get the phrase, record it, run again to submit.
CHALLENGE_CACHE=".consent-challenge.json"

auth=(-H "Authorization: Bearer ${SPEECHIFY_API_KEY}" -H "Speechify-Version: ${VERSION}")

# 1. Get (or reuse) a consent challenge. Its `phrase` is what the speaker must
# read aloud; `id` ties the recording to this consent on the create.
if [ ! -f "$CHALLENGE_CACHE" ]; then
curl --fail-with-body --silent --show-error \
-X POST "${BASE}/v1/voices/consent-challenges" \
"${auth[@]}" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg n "$CONSENT_FULL_NAME" '{full_name: $n}')" \
> "$CHALLENGE_CACHE"
fi
challenge_id=$(jq -r '.id' < "$CHALLENGE_CACHE")
phrase=$(jq -r '.phrase' < "$CHALLENGE_CACHE")
expires_at=$(jq -r '.expires_at' < "$CHALLENGE_CACHE")

# 2. Make sure we have both recordings before spending the (single-use) challenge.
if [ ! -f "$SAMPLE" ] || [ ! -f "$CONSENT" ]; then
echo ""
echo "Consent required. Have ${CONSENT_FULL_NAME} record themselves reading this phrase, exactly as written:"
echo ""
echo " \"${phrase}\""
echo ""
echo "Then provide these files and re-run (paths override via SAMPLE_PATH / CONSENT_RECORDING_PATH):"
[ ! -f "$SAMPLE" ] && echo " sample: ${SAMPLE} (${CONSENT_FULL_NAME}'s voice, 10-30s of clean speech)"
[ ! -f "$CONSENT" ] && echo " consent: ${CONSENT} (the SAME person reading the phrase above)"
echo ""
echo "Challenge expires ${expires_at}. If it has expired, delete ${CHALLENGE_CACHE} and re-run."
exit 1
fi

# 3. Clone the voice. POST /v1/voices is multipart/form-data — `curl -F` builds
# the body and sets the Content-Type boundary automatically.
create_body=$(mktemp)
trap 'rm -f "$create_body"' EXIT

http_status=$(curl --silent --show-error --output "$create_body" --write-out '%{http_code}' \
-X POST "${BASE}/v1/voices" \
-H "Authorization: Bearer ${SPEECHIFY_API_KEY}" \
"${auth[@]}" \
-F "name=cookbook-cloned-voice" \
-F "gender=male" \
-F 'consent={"fullName":"Jane Doe","email":"jane@example.com"};type=text/plain' \
-F "sample=@${SAMPLE};type=audio/wav")
-F "consent_challenge_id=${challenge_id}" \
-F "sample=@${SAMPLE}" \
-F "consent_recording=@${CONSENT}")

if [ "$http_status" = "402" ]; then
echo ""
Expand All @@ -45,19 +85,22 @@ if [ "$http_status" -lt 200 ] || [ "$http_status" -ge 300 ]; then
echo "POST /v1/voices → ${http_status}" >&2
cat "$create_body" >&2
echo >&2
echo "If this is a consent_* error, the challenge may be spent or expired — delete ${CHALLENGE_CACHE} and re-run." >&2
exit 1
fi

rm -f "$CHALLENGE_CACHE" # challenge is spent

voice_id=$(jq -r '.id' < "$create_body")
display_name=$(jq -r '.display_name' < "$create_body")
voice_type=$(jq -r '.type' < "$create_body")
echo "Cloned voice created: ${voice_id} (${display_name}, type=${voice_type})"

# Ensure we always delete the cloned voice, even on failure of step 2.
# Ensure we always delete the cloned voice, even on failure of step 4.
cleanup() {
del_status=$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \
-X DELETE "${BASE}/v1/voices/${voice_id}" \
-H "Authorization: Bearer ${SPEECHIFY_API_KEY}")
"${auth[@]}")
if [ "$del_status" -ge 200 ] && [ "$del_status" -lt 300 ]; then
echo "Deleted cloned voice ${voice_id}"
else
Expand All @@ -66,10 +109,10 @@ cleanup() {
}
trap 'cleanup; rm -f "$create_body"' EXIT

# 2. Synthesize speech using the cloned voice — pass its id as voice_id.
# 4. Synthesize speech using the cloned voice — pass its id as voice_id.
speech_response=$(curl --fail-with-body --silent --show-error \
-X POST "${BASE}/v1/audio/speech" \
-H "Authorization: Bearer ${SPEECHIFY_API_KEY}" \
"${auth[@]}" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg vid "$voice_id" '{
input: "Hello from a voice cloned with the Speechify API.",
Expand All @@ -81,4 +124,4 @@ speech_response=$(curl --fail-with-body --silent --show-error \
printf '%s' "$speech_response" | jq -r '.audio_data' | base64 -d > output.mp3
echo "Wrote output.mp3"

# 3. Cleanup runs from the EXIT trap.
# 5. Cleanup runs from the EXIT trap.
10 changes: 10 additions & 0 deletions recipes/audio/python/native/voice-cloning/.env.example
Original file line number Diff line number Diff line change
@@ -1 +1,11 @@
# Get a key at https://platform.speechify.ai/api-keys
# Voice cloning must be enabled on your plan.
SPEECHIFY_API_KEY=

# Full name of the person consenting to have their voice cloned.
CONSENT_FULL_NAME=Jane Doe

# Optional — override where the recipe reads the audio from.
# Defaults to sample.wav and consent.wav in the recipe folder.
# SAMPLE_PATH=./sample.wav
# CONSENT_RECORDING_PATH=./consent.wav
48 changes: 34 additions & 14 deletions recipes/audio/python/native/voice-cloning/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Text-to-Speech: voice cloning (Python, native REST)

The same as [`voice-cloning`](../../sdk/voice-cloning) — clone → synthesize → delete —
but calling the REST API directly with `requests` + multipart form-data instead of the
`speechify-api` SDK.
The same as [`voice-cloning`](../../sdk/voice-cloning) — clone → synthesize → delete, with
**verified consent** — but calling the REST API directly with `requests` + multipart
form-data instead of the `speechify-api` SDK.

## Prerequisites

Expand All @@ -11,6 +11,9 @@ but calling the REST API directly with `requests` + multipart form-data instead
pointing to [Speechify pricing](https://speechify.ai/pricing) (the API returns
`402 voice_cloning_not_included`).
- Python 3.10+ and [uv](https://docs.astral.sh/uv/)
- **Two recordings of the same consenting person** (see [Consent](#consent)):
- a voice **sample** to clone — 10–30s of clean speech
- a **consent recording** — that person reading the challenge phrase this recipe prints

## Setup

Expand All @@ -21,26 +24,43 @@ uv sync

## Run

Because consent is verified against a phrase the API generates, this is a two-step run:

```bash
uv run main.py
uv run main.py # 1st run: prints the phrase to read, then exits
# record the speaker reading that phrase → consent.wav, and their voice → sample.wav
uv run main.py # 2nd run: clones, synthesizes, deletes
```

Produces an `output.mp3` spoken in the cloned voice, then removes the cloned voice.
Put `sample.wav` and `consent.wav` in the recipe folder, or point `SAMPLE_PATH` /
`CONSENT_RECORDING_PATH` at them. Produces an `output.mp3` in the cloned voice, then
removes the cloned voice.

## What it does

- `POST /v1/voices` (**multipart/form-data**): fields `name`, `gender`, `consent`, and
a `sample` file part. Pass them via `data=` + `files=` and `requests` sets the
`Content-Type` boundary automatically — do **not** set it yourself.
- `POST /v1/voices/consent-challenges` (JSON `{ full_name }`) — returns a `phrase` the
speaker must read aloud and an `id`; cached in `.consent-challenge.json` between runs
(single-use).
- `POST /v1/voices` (**multipart/form-data**): fields `name`, `gender`,
`consent_challenge_id`, and `sample` + `consent_recording` file parts. Pass them via
`data=` + `files=` and `requests` sets the `Content-Type` boundary — do **not** set it
yourself.
- `POST /v1/audio/speech` with the returned voice's `id` as `voice_id`.
- `DELETE /v1/voices/{id}` — cleans up so personal voices don't accumulate.

All calls pin `Speechify-Version: 2026-09-13`, the API version the verified-consent flow
ships on.

## Consent

`consent` is a **required** JSON string attesting you have the speaker's permission to
clone their voice (`{"fullName": "...", "email": "..."}`). Only clone voices you are
authorized to. The bundled `fixtures/spacewalk.wav` is ~26s of NASA ISS spacewalk audio
(U.S. government work, public domain); replace it with your own consented sample for
real use.
Cloning requires **verified consent**. You create a consent challenge, the speaker records
themselves reading the returned `phrase`, and that recording is sent as `consent_recording`
and retained as the consent record. The `consent_recording` **must be the same person** as
the voice `sample` — so there is no shippable sample that comes with valid consent, and this
recipe is deliberately bring-your-own-audio. Only clone voices you are authorized to.

> Voice cloning reference: https://docs.speechify.ai/tts/guides/voice-cloning
> The old `consent` JSON field (`fullName` + `email`) is removed on `Speechify-Version:
> 2026-09-13` — see the
> [migration guide](https://docs.speechify.ai/build/guides/deprecations/migrating-voice-cloning-consent).
>
> Voice cloning reference: https://docs.speechify.ai/build/guides/voice-cloning/overview
Binary file not shown.
Loading
Loading