Change Voice
One video in. The same video out, with the speech re-performed by a voice you chose.
The picture never moves. The timing never moves. What changes is who is speaking, and the entire craft of this skill is in three decisions the endpoint cannot make for you: which voice, which spans of audio to replace, and whether the result is actually good. The HTTP call in the middle is one request.
Every HTTP mechanic here belongs to the pack, not to this skill. Auth, strict
bodies, status codes, rate limits and error envelopes are written out once in
skills/novoads-api/SKILL.md and its
reference.md. Read those for mechanics; this file
names the endpoint and the fields that matter to a re-voice.
Before anything: this runs on a Novoads account
- Base URL:
https://api.novoads.ai/v1(host overridable withNOVOADS_BASE_URL— host only, you append/v1). - Auth:
Authorization: Bearer $NOVOADS_API_KEY, read from.envat the repo root. - Check:
./scripts/check-novoads-env.sh. If it is missing, run./scripts/setup.sh. - ffmpeg on your machine. Hard dependency, not optional: the local speech check, the
casting measurement and the whole assembly are local ffmpeg work.
ffmpeg -versionbefore you start. - Never print API keys, commit
.env, or paste a key intoMASTER_CONTEXT.md.
REST key required. A Novoads MCP connector is not a substitute. If
NOVOADS_API_KEYis missing or still the placeholder, stop before any generation work and tell the user: "Before continuing, create an API key at https://novoads.ai/dashboard/settings?tab=api and paste it into.env." That holds even whenmcp__novoads__*tools are connected and authenticated in the session. Never callmcp__novoads__*tools from this repo's workflows: they are a different surface with different behavior, including the units they quote costs in. Repo installs verify with./scripts/check-novoads-env.sh; a solo install checksNOVOADS_API_KEYin the environment.
Pack version. Every /v1 response carries X-Novoads-Pack-Version; mention a newer pack at https://github.com/novoads/agent-skills only when that header names a version NEWER than this file's metadata.packVersion — equal or older is nothing to say, and it is never a reason to stop.
A 401 means the key is wrong, revoked, or from another account. A 403 with
error.details.reason of plan_required or subscription_inactive means the key is
fine and the account has no live subscription. Different problems, different fixes — say
which one it is.
No account yet? https://novoads.ai/?utm_source=claude-code&utm_medium=github&utm_campaign=skill-pack The entry offer is a $1 trial. Never call it free.
If this deployment does not offer voice changes yet. The endpoint is behind a flag.
With it off, POST /v1/voice-changes and a kind: "voice-change" estimate both answer
400 invalid_input with a message naming what the API does offer, and neither the
path nor the estimate arm appears in GET /v1/openapi.json. Read that as "this
deployment cannot do it", say so in one line, and stop. It is not a 404 and it is not
a retry.
What the endpoint does, and the four things it does not
It takes a source you already have, converts the speech in it to the voice you name,
and returns audio. 200, not 202 — there is no job to poll.
It preserves timing frame-accurately. That is the whole reason this exists rather than a text-to-speech call: the take drops back onto the original picture and the lips still match. Measured on this skill's acceptance run, across 33 words, the largest per-word start drift between the source's transcript and the finished output's was 0.021 seconds.
It matches loudness for you. The converted audio is gained to within 1.5 dB of the source's own measured mean before it is stored, so the take arrives at the level of the thing it replaces.
Four things it does not do, each one a real assumption callers arrive with:
| It does not | So |
|---|---|
| translate | the words that come back are the words that went in |
| fix pronunciation, or change an accent | it reproduces the source's phonemes, mistakes included. A mispronounced brand name comes back mispronounced in a nicer voice. See Gate 3 and the failure modes |
| detect speech | the gate is "does this have an audio track". A music bed converts into vocal noise, billed in full, and answers 200. Gate 1 is yours and it is free |
| return video | muxing the take back over the picture, and choosing which spans to replace, is local work. Deliberately: those are creative decisions |
The gates run in order. Do not skip to the conversion.
Gate 1 — is there actually a voice in this file?
Free, local, and it runs before the upload, not after.
python3 skills/change-voice/scripts/check-speech.py <the source file>
- exit 0,
SPEECH— proceed. Write down the reportedspeech runs A to B: A is the fence the assembly step needs, and the reported pitch is the casting brief's spine. - exit 2,
NO-SPEECH— stop, and do not call the endpoint. Say plainly what it is: this file has no talking voice in it, and the endpoint would convert it into vocal noise and charge for the minute. If the ask was really a music bed, that isPOST /v1/musicand a different skill. If the speech starts later in the file, run with--json, readspeechStart, and cut a clip that contains it. - exit 3,
UNCLEAR— the two measurements disagree. Play the file. Proceeding on the optimistic reading is how a charged conversion of nothing happens.
The check reads a whole file, and it has three blind spots worth stating out loud when they apply: singing reads as speech (one singer is one voice); narration over a music bed reads as speech, correctly — but the endpoint will convert the bed too, so either fence it in assembly or convert a cut where the bed plays alone; and a sustained single pitch (a drone, a synth pad, an organ note) reads as speech, because it is one periodic thing in the human range. That last one shows in the numbers rather than the exit code: a reported pitch spread near 0 Hz is a machine, not a person. Read it before you proceed.
Gate 2 — price it, announce it, wait
Upload the source, then price it. Both are free.
# the source: a video, or an audio file. This is the ONE endpoint here that takes
# audio-only uploads (audio/mpeg, audio/wav) as well as video/mp4.
curl -sS -X POST https://api.novoads.ai/v1/uploads -A novoads-skill/change-voice \
-H "Authorization: Bearer $NOVOADS_API_KEY" -H 'Content-Type: application/json' \
-d '{"contentType":"video/mp4","sizeBytes":<exact byte count>}'
# → 201 { assetId, uploadUrl, method, headers } — PUT the bytes with `headers` VERBATIM
curl -sS -X POST https://api.novoads.ai/v1/estimates -A novoads-skill/change-voice \
-H "Authorization: Bearer $NOVOADS_API_KEY" -H 'Content-Type: application/json' \
-d '{"kind":"voice-change","assetId":"<assetId>"}'
# → { credits, balance, sufficient }
# the conversion is not the only charge in this run. Gate 7 reads the finished file
# back, and reading it against the source needs the source's words too. Price that
# here, on the same assetId, unless you already have the source's own script.
curl -sS -X POST https://api.novoads.ai/v1/estimates -A novoads-skill/change-voice \
-H "Authorization: Bearer $NOVOADS_API_KEY" -H 'Content-Type: application/json' \
-d '{"kind":"transcript","assetId":"<assetId>"}'
A source already rendered by this API needs no upload — pass its jobId instead of
assetId, in both the estimate and the conversion. At most one of the two, in either
body.
Name the source in the estimate. The price is per minute of source audio, so a quote with no source is the one-minute minimum and says nothing about a four-minute file. The transcript arm bills per minute the same way, so the same rule applies to it.
Announce the whole run, not just the conversion. The conversion is the large charge and it is not the only one: the verification in Gate 7 is a charged call too, and reading it against the source is a second one unless you already hold the source's script. Say so in the same line — the conversion at the price quoted above, the source transcript at the price quoted beside it, and the read-back of the finished file quoted at Gate 7, because the file it prices does not exist yet. A run that announces one number and spends three times is a run the operator did not actually approve.
Then proceed. This is an announcement, not a question, with two exceptions:
sufficient: false is a blocker — name the shortfall and give the topUpUrl; and a
balance that covers this conversion but not a second one is worth one clause, because
a different voice is a different conversion and a new charge.
Never state a price from memory, and never estimate one of these calls by reasoning from another. There are no rate tables in this repo, the estimate is free on every arm, and it is the only legitimate source of a number.
Gate 3 — the casting brief
Three inputs, all free, and none of them is the voice catalog yet.
- Who is on screen. Pull two or three frames and look at them.
Apparent gender and apparent age are what the catalog filters on. Note them as apparent, because that is all a frame supports.ffmpeg -v error -ss 3 -i source.mp4 -frames:v 1 f1.jpg - What language, and what register. The transcript tells you the language and shows
you the copy. If you have the source's own script, that is enough and this costs
nothing; otherwise
POST /v1/transcriptson the sameassetIdreads it back — a charged call, quoted at Gate 2 and wanted again in Gate 7, so it is one charge and not two. Never quote it from memory:curl -sS -X POST https://api.novoads.ai/v1/estimates -A novoads-skill/change-voice \ -H "Authorization: Bearer $NOVOADS_API_KEY" -H 'Content-Type: application/json' \ -d '{"kind":"transcript","assetId":"<assetId>"}' - What the current voice measures.
check-speech.pyalready reported it: a median pitch and a spread. That number, not an adjective, is what Gate 4 ranks against.
If the ask is an accent or a pronunciation, stop here and say so. A voice change
moves timbre and register; it does not re-pronounce anything. Swapping to a voice
labelled british gives you a British-sounding instrument saying the source's own
phonemes. The two routes that genuinely answer that ask:
- Re-render the ad with a different actor, and spell the awkward word phonetically in
the new script (
Git-Hub,no-vo-ads).clone-video-adis that path when there is a source ad to work from. - Lay a fresh
POST /v1/voiceoverstake over a span with no lips in frame. On a span where the actor is visible and speaking, a TTS line will not match the mouth and the seam is the first thing a viewer sees.
Gate 4 — audition, then STOP
Read the catalog filtered. Unfiltered it is thousands of voices and megabytes of
JSON; on 2026-08-12 gender=male&language=en alone returned 5,362 entries and 3.8 MB.
curl -sS "https://api.novoads.ai/v1/voices?gender=male&age=young&accent=american&language=en" \
-A novoads-skill/change-voice -H "Authorization: Bearer $NOVOADS_API_KEY" > voices.json
gender and age match whole values (male/female, young/middle_aged/old);
accent is a substring, so american also finds latin american; language matches
the base subtag, so es finds es-CL. A voice with no labels drops out of a filtered
list except your own organization's clones, which come back under every filter and
sort after the matches. limit is a cap applied after filtering, not a page — and
because the list is ordered by name, a small limit hands you one letter of the
alphabet rather than a sample.
Then measure the candidates against the source and audition them:
python3 skills/change-voice/scripts/match-voice.py \
--source source.mp4 --voices voices.json --sample 24 --top 3
It downloads each candidate's previewUrl, measures its pitch the way it measured the
source's, and ranks by distance in semitones. Play the shortlist. Then:
╔═══════════════════════════════════════════════════════════════════════╗
║ CASTING GATE — BLOCKING. The human picks the voice. ║
║ Never default a voiceId. A voice nobody listened to is a performance ║
║ charged for without being heard. ║
╚═══════════════════════════════════════════════════════════════════════╝
Why measurement and not the descriptions. Casting from written labels miscasts, and it did: on this skill's own reference ad the top pick by description measured a flat monotone, and the second sat most of an octave above the actor on screen. The labels narrow the field correctly and say nothing about register, which is exactly what a viewer hears as "that is not his voice".
And why the script does not decide. It has never heard the ad. A preview is a few seconds of unrelated copy: it carries timbre and register, not the delivery this ad needs. Under about two semitones from the source reads as the same register, and inside that band the choice is listening.
Gate 5 — convert
curl -sS -X POST https://api.novoads.ai/v1/voice-changes -A novoads-skill/change-voice \
-H "Authorization: Bearer $NOVOADS_API_KEY" -H 'Content-Type: application/json' \
-d '{"assetId":"<assetId>","voiceId":"<the one the human picked>"}'
# → 200 { jobId, assetId, url, expiresInSeconds, creditsCharged, billedMinutes, voiceId }
Fifteen seconds of source came back in about seven on the acceptance run. Download
url immediately — it is minted per response and time-limited, and the assetId is
the durable handle.
Write down jobId and creditsCharged the moment they land. If the response never
reaches you, do not call again: the audio was probably rendered and charged.
GET /v1/generations?kind=audio will show it.
Converting the same source in the same voice again is free and returns the same
jobId and assetId with creditsCharged: 0 and a freshly minted URL. So a retry
after a timeout is not a second charge — verified on the acceptance run, in the response
and in the balance. A different voice is a different conversion and a new charge, so
auditioning by conversion is the expensive way to do Gate 4.
Gate 6 — assembly, which is where the ad is won or lost
The endpoint converted everything it heard, including the sound effects, the room tone and any music. Those already exist, correctly, in the original track. So the finished audio is the original everywhere except the spans where somebody is talking.
python3 skills/change-voice/scripts/assemble-voice-change.py \
--source source.mp4 --converted take.mp3 \
--speech-start <the A from Gate 1> [--speech-end <B>] --out final.mp4
It builds one timeline — original head, converted body, original tail — muxes it against the video stream copied bit for bit, and then measures the finished file against the source. Exit 2 means the file was written and is out of tolerance: read the numbers rather than shipping it.
Three things it does deliberately, all of which matter:
- No crossfades at the seams.
acrossfadeshortens the total by its own duration, which slides every later word out from under the lips. The seams get equal-length 20 ms fades, which cost no time at all. - It states the channel matrix. The take is mono and an ad's track is usually stereo; ffmpeg's default mono-to-stereo rematrix silently costs 3 dB. Measured on the acceptance run before it was fixed: a take sitting 0.6 dB under its source came out of the mux 3.4 dB under it, with every boundary correct.
- It re-measures the object it wrote, not the object it intended to write.
Set the fence at a point where nobody is talking. Gate 1's speechStart is that
point on a normal ad. Where speech is interleaved with effects, run the assembly per
span, or accept the conversion across an effect and listen to it before shipping.
Gate 7 — verify by transcribing your own output
Doctrine: shared/references/craft.md § 1. The call below is this surface's; the rule is not this skill's.
This step spends. It is the last charge in the run and the one most easily mistaken for housekeeping. Upload the finished file, price the read-back, say the number, then read it.
# upload the finished file, then price the read-back on ITS assetId — the file did
# not exist at Gate 2, so this is the first moment it can be quoted at all
curl -sS -X POST https://api.novoads.ai/v1/estimates -A novoads-skill/change-voice \
-H "Authorization: Bearer $NOVOADS_API_KEY" -H 'Content-Type: application/json' \
-d '{"kind":"transcript","assetId":"<the finished file>"}'
curl -sS -X POST https://api.novoads.ai/v1/transcripts -A novoads-skill/change-voice \
-H "Authorization: Bearer $NOVOADS_API_KEY" -H 'Content-Type: application/json' \
-d '{"assetId":"<the finished file>"}' | jq -r '.text'
Skipping it to save the charge is not an option on offer: an unread run is not finished, and the whole point of the gate is that a conversion can drop a word silently. If the budget only stretches to one read-back, it is this one.
Read it against the source's transcript, word for word. A conversion that dropped or
mangled a word is a failed run, not a note in the report. Timings come back in
seconds, so comparing words[].start between the two transcripts is also the
cheapest proof that the picture and the new voice still agree.
Then watch the lip window — ten seconds of the finished file with the actor's mouth in frame, at full size. The transcript proves the words survived. Only your eyes prove the mouth still owns them.
Report the loudness delta the assembly step printed. A number nobody states is a number nobody checked.
The calls
POST /v1/uploads { contentType: "video/mp4" | "audio/mpeg" | "audio/wav" | …,
sizeBytes: <exact> }
→ 201 { assetId, uploadUrl, method, headers } — PUT the bytes
with `headers` VERBATIM. Free. The URL expires in 900s; the
assetId does not expire.
POST /v1/estimates { kind: "voice-change" | "transcript", assetId | jobId }
Both arms are used here, and both are free. Price the
conversion and the read-backs, not just the conversion.
→ { credits, balance, sufficient, shortBy?, topUpUrl? }
Strict. At most one source. No source = the one-minute minimum.
GET /v1/voices ?gender=&age=&accent=&language=&limit=
→ { voices: [{ id, name, source, previewUrl?, languages?,
category?, labels? }] } Reads only, spends nothing.
POST /v1/voice-changes { assetId | jobId, voiceId (REQUIRED), productId? }
→ 200 { jobId, assetId, url, expiresInSeconds, creditsCharged,
billedMinutes, voiceId }
ALREADY DONE — nothing to poll. Audio out; you mux.
POST /v1/transcripts { assetId | jobId }
→ 200 { text, words[], segments[], srt, creditsCharged }
CHARGED, per minute of source. Timings in SECONDS. The
same source twice is free, which is why Gate 3's read of
the source is the same charge Gate 7 compares against.
GET /v1/generations?kind=audio → the recovery path when a response never arrived.
Failure modes
400naming a 5-minute cap. The source is too long, and nothing was charged. Split it and convert the parts; do not retry the whole file.400 invalid_inputnaming what the deployment renders. Voice changes are off on this deployment. Not a retry, not a404. Say so and stop.404on the voiceId. The voice is not yours, or it went inactive upstream. This endpoint never substitutes a near-enough voice — that is deliberate, because you cast a specific performance. Re-list, re-audition, re-pick. If it went inactive mid-request the credits are already refunded.409. Either the source job has not succeeded yet, or it has no audio to convert, or a conversion of this source in this voice is already in flight — the message names that job id. Wait a moment and call again: the stored audio comes back without a second charge. Anything charged before a409is refunded.429withdetails.reason: voice_change_concurrency_limit. Ten conversions are already open for the organization. It is its own queue, counted apart from renders, captions, transcripts and voice-overs, so this never means "stop generating video". Sleep onRetry-Afterand call again. Branch ondetails.reason, never on the status alone.402. Not enough credits;detailscarriesrequiredandavailable. Report both and the top-up path. Do not retry.502. The provider failed and the credits are refunded automatically. Read the message for whether the refund has landed or is queued.- A
403carrying Cloudflare'serror code: 1010. Not your key. The edge refuses Python's stdliburllibUser-Agent outright, and the body is an edge page rather than the API's{"error":{…}}envelope. Usecurl, or send a realUser-Agent. Do not regenerate the key or tell the user their plan lapsed. - The new voice says a word wrong. It was said wrong in the source. Nothing about
conversion can fix it, and re-running with another voice buys the same mistake in
another timbre. Two fixes: re-render the source with the word spelled phonetically and
convert that (see Gate 3), or, only on a span with no lips in frame, splice a
POST /v1/voiceoverstake of the corrected line. - The sound effect turned into a voice. The fence was wrong, or absent. The effect
lives in the original track; re-run the assembly with the right
--speech-start. This costs nothing — the conversion is already paid for and cached. - The finished file plays quieter than the source. Read the assembly step's numbers. If the take matched the source but the output did not, the loss is in the mux, and it is 3 dB shaped like a mono-to-stereo rematrix.
- The mouth stops matching partway through. Something re-timed the audio: a
crossfade, an
atempo, or a take made from a different cut of the source. The assembly script warns when the take's duration and the source's disagree by more than a quarter second. Never speed a take up to fit.
Hard rules
- The human picks the voice. Never default a
voiceId. - Gate 1 before the upload. A music bed converts, succeeds, and bills.
- Every number shown to a user comes from
POST /v1/estimatesin this session. This file contains no prices and neither does anything else in this repo. - Every charged call in the run is announced, not just the big one. The conversion is one of three: the verification transcript is charged, and so is the source transcript when you need it.
- The picture is copied, never re-encoded, and the effects come from the original track.
- Verify by transcribing your own output. A run that was not read back is not finished.
- A different voice is a new charge. Audition with previews, not with conversions.
- Never promise a translation, an accent, or a pronunciation fix.
- Work in
outputs/<ad-name>/, which is gitignored. Never invent a new top-level directory.
Output format
- SOURCE — what it is, how long, and what Gate 1 measured: speech present, the span, the current voice's pitch.
- BRIEF — who is on screen, the language, the register the replacement has to hold.
- COST — the live estimates, in one line, before anything is charged: the conversion and the transcript read-backs the verification needs. One number for one of three charges is not the cost of the run.
- SHORTLIST — two or three candidates with their measurements and their preview files. Then stop and wait for the pick.
- Then convert, assemble, and verify.
- DELIVERY — the output path, the loudness delta, the transcript read against the source, and what you looked at in the lip window.