| name | change-voice |
| metadata | {"packVersion":"1.2.0"} |
| description | Replaces the voice in a video or audio file with one from the Novoads voice catalog, over the REST API. Converts the SPEECH in the source to a voice you pick, keeps the original timing so lip-sync survives, and returns audio that gets muxed back over the untouched picture locally. Casts by MEASURING the source voice against auditioned candidates instead of reading labels, refuses a source with no speech before anything is charged, fences sound effects and music so they survive from the original track, and verifies the finished file by transcribing it. Use when an actor's voice sounds robotic, flat, thin or simply wrong, and for "change the voice", "voice swap", "swap the voice on this ad", "re-voice my ad", "the actor's voice sounds bad", "make him sound human", "different voice for this video", "speech to speech", "same video better voice". Not for narrating a silent clip (that is a voice-over), not translation, and it changes neither pronunciation nor accent. |
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 with NOVOADS_BASE_URL —
host only, you append /v1).
- Auth:
Authorization: Bearer $NOVOADS_API_KEY, read from .env at 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 -version
before you start.
- Never print API keys, commit
.env, or paste a key into MASTER_CONTEXT.md.
REST key required. A Novoads MCP connector is not a substitute. If
NOVOADS_API_KEY is 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 when mcp__novoads__* tools are connected and authenticated in
the session. Never call mcp__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 checks NOVOADS_API_KEY in 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 reported speech 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 is
POST /v1/music and a different skill. If the speech starts later in the file, run
with --json, read speechStart, 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.
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>}'
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>"}'
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.
ffmpeg -v error -ss 3 -i source.mp4 -frames:v 1 f1.jpg
Apparent gender and apparent age are what the catalog filters on. Note them as
apparent, because that is all a frame supports.
- 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/transcripts on the same assetId reads 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.py already 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-ad
is that path when there is a source ad to work from.
- Lay a fresh
POST /v1/voiceovers take 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>"}'
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.
acrossfade shortens 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.
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
400 naming 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_input naming what the deployment renders. Voice changes are off on
this deployment. Not a retry, not a 404. Say so and stop.
404 on 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 a 409 is refunded.
429 with details.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 on Retry-After and call again. Branch on details.reason, never on the status
alone.
402. Not enough credits; details carries required and available. 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
403 carrying Cloudflare's error code: 1010. Not your key. The edge refuses
Python's stdlib urllib User-Agent outright, and the body is an edge page rather than
the API's {"error":{…}} envelope. Use curl, or send a real User-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, , splice a
take of the corrected line.
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/estimates in 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.