Skip to main content

Coding Guide 6 — Give your Butter Bot a new voice

Reshape the stock voice's pitch and speed, or install and tune a completely new flite voice.

A
Written by Albert Gajsak

Reshape the robot's pitch, speed, and intonation with three lines of code — then go further and teach it an entirely new voice. The advanced guide to Butter Bot's text-to-speech.

What you'll do

Every word your Butter Bot speaks comes from flite, a tiny text-to-speech engine from Carnegie Mellon University that runs entirely on the robot — no cloud, no account, no internet. Guides 2–5 changed what the robot says. This guide changes how it sounds, in two escalating stages:

  • Tune the stock voice — pitch, speaking speed, and how expressive the intonation is, each one line of code.

  • Install a whole new voice — swap the robotic stock voice for one of flite's smoother, more human ClusterGen voices (the female cmu_us_slt is this guide's worked example; two more are the same download away), then tune the newcomer for speed and loudness.

This is the most advanced guide in the series, but "advanced" mostly means unfamiliar territory, not lots of code: the whole voice swap is a folder copy plus three changed lines. It's designed that way on purpose.

Prefer to let an AI do the typing?Guide 3 (Claude Code) and Guide 4 (Codex) show how. Like Guide 5, every code block here names its file, so you can paste a whole section into Claude Code or Codex and say "do this".

What you need

Item

Notes

Everything from Guide 2

Working ESP-IDF setup, the cloned ButterBot-Firmware project, and at least one successful build + flash. Do Guide 2 first.

The voice-modding branch

One git checkout away — the next section explains what it is and why it exists.

A voice download

Free, straight from the flite project — Topic 3 tells you exactly where. No account needed.

The voice-modding branch

⚠️ This guide needs a different branch

Everything from Topic 2 onward exists only on the voice-modding branch of ButterBot-Firmware. The master branch — the one Guides 2–5 use, and the one your robot shipped with — can no longer receive changes, and it is missing the engine-room work that new voices depend on. If you try to run one of flite's nicer voices on master, the robot doesn't just sound wrong — it crashes mid-sentence with a task_wdt: IDLE1 watchdog error (the "why" is in Topic 2).

Switching is one command inside your existing ButterBot-Firmware folder from Guide 2, plus the usual submodule refresh:

git checkout voice-modding
git submodule update --init --recursive

Or, if you're starting from scratch, clone the branch directly:

git clone --recursive -b voice-modding https://github.com/CircuitMess/ButterBot-Firmware-Public.git

Everything else about the workflow is unchanged: idf.py build, idf.py -p PORT flash, exactly as in Guide 2. To go back to the standard firmware later, git checkout master (plus the same git submodule update line), rebuild, reflash.

Topic 1 — Make the stock voice yours

Before installing a new voice, let's bend the one you have. The stock voice is called cmu_us_kal16, and like every flite voice it carries a small set of tunable features — named values you can override from the firmware with one function call each. No voice files get touched.

Files you'll touch

File

Repo

What you do

main/src/Audio/SpeechGen.cpp

ButterBot-Firmware

Add feat_set_float(...) lines in the constructor

The three knobs

The voice is created once, in the SpeechGen constructor. Right after the register_cmu_us_kal16(nullptr) call is where your overrides go — they apply to every sentence the robot speaks from then on:

// FILE: ButterBot-Firmware/main/src/Audio/SpeechGen.cpp  (edit, inside the SpeechGen constructor)
voice = register_cmu_us_kal16(nullptr);feat_set_float(voice->features, "duration_stretch", 1.1f);       // new: speaking speed. Higher = slower.
feat_set_float(voice->features, "int_f0_target_mean", 95.0f);    // new: average pitch, in Hz.
feat_set_float(voice->features, "int_f0_target_stddev", 11.0f);  // new: intonation range, in Hz.

The values above are exactly what the stock voice already uses, so this block changes nothing yet — it's your baseline. What each one does:

Feature

Stock value

What it controls

duration_stretch

1.1f

A multiplier on how long every sound lasts. 1.0 is the voice's natural pace, 1.3 is noticeably slower and clearer, 0.8 is hurried. Sensible range ≈ 0.5–2.0.

int_f0_target_mean

95.0f

The voice's average pitch, in Hz. 95 is a low male voice; 180–220 lands in typical female range; 300 is a cartoon chipmunk.

int_f0_target_stddev

11.0f

How far the pitch is allowed to swing around that average, in Hz. 0.0f gives a perfectly flat robotic monotone; 25–30 sounds theatrical.

f0_shift

(unset, = 1.0)

A pitch multiplier applied on top of the mean — 1.2f means "20% higher, whatever the base is". Handy when you want to nudge pitch without memorizing Hz values.

Two ready-made personalities to try. Build, flash, and short-press the power button to make it talk (or ask for a joke):

// FILE: ButterBot-Firmware/main/src/Audio/SpeechGen.cpp  (edit — pick ONE block)// "Espresso overdose": fast, high, excitable
feat_set_float(voice->features, "duration_stretch", 0.85f);
feat_set_float(voice->features, "int_f0_target_mean", 180.0f);
feat_set_float(voice->features, "int_f0_target_stddev", 25.0f);// "Ancient butler": slow, deep, unimpressed by everything
feat_set_float(voice->features, "duration_stretch", 1.35f);
feat_set_float(voice->features, "int_f0_target_mean", 70.0f);
feat_set_float(voice->features, "int_f0_target_stddev", 4.0f);

⚠️ The type must match, or nothing happens

These features are looked up by type. Set a float feature with feat_set_int() (or vice versa) and flite silently ignores your value and uses the default — no error, no warning, just a robot that stubbornly sounds the same. All four knobs above are floats; write the f suffix (95.0f) and use feat_set_float. The one integer knob you'll meet later (mlsa_speed_param, Topic 4) uses feat_set_int.

What about volume? Careful readers will notice there's no loudness knob in the table. That's flite being honest: the engine has no per-voice volume feature. The stock voice's loudness is what it is; the new-voice family in Topic 3 does have a gain setting, covered in Topic 4.

Bonus: everything in this topic also works on the regular master branch — these knobs are plain flite features that the stock firmware already understands. It's the rest of the guide that needs voice-modding.

Topic 2 — What a flite voice actually is

To install a voice you only need to know three things about flite's world:

1. A voice is generated C code. There are no audio files, no models to download at runtime. A flite voice is a folder of .c files — enormous machine-generated tables baked straight into the firmware. That's why installing one is a folder copy: the build system compiles whatever voices are present, and the firmware registers the one it wants by calling that voice's register_...() function.

2. There are two voice families.Diphone voices (like the stock cmu_us_kal16) glue together tiny recorded snippets — cheap to run, unmistakably robotic. ClusterGen voices (like cmu_us_slt, this guide's worked example) generate speech from a statistical model — noticeably smoother and more human, at the cost of a lot more math per syllable.

3. That math is exactly why the branch exists. ClusterGen voices upstream do their number-crunching in double-precision floating point. The robot's ESP32-S3 chip only has single-precision hardware — doubles get emulated in software, which made ClusterGen synthesis slower than real time. The speech thread would hog its entire CPU core, a watchdog would notice the core never idles, and the robot would reboot mid-word: the task_wdt: IDLE1 crash mentioned earlier. The voice-modding branch converts flite's two synthesis engines (components/flite/src/cg/cst_mlsa.c and cst_mlpg.c) to single-precision — a one-time fix that makes every ClusterGen voice work, which is what makes the rest of this guide a folder-copy exercise.

One hard rule survives all of this:

⚠️ 16 kHz voices only

The robot's entire audio chain — microphone, effects, amplifier — is hardwired to a 16 kHz sample rate. A voice recorded at any other rate will compile and run, but come out sounding like a broken tape deck. Every voice recommended in this guide is 16 kHz; if you go hunting for others, check before you copy (the voice's ..._cg.c file contains its sample rate — look for 16000).

Where voices live in this project:

ButterBot-Firmware/components/flite/
├── src/lang/
│   ├── cmu_us_kal16/    ← the stock voice (diphone)
│   ├── usenglish/       ← shared English text processing (all voices use this)
│   ├── cmulex/          ← shared pronunciation dictionary (all voices use this)
│   └── cmu_us_slt/      ← your first new voice will land here (Topic 3)
└── include/lang/
    ├── usenglish/
    ├── cmulex/
    └── cmu_us_slt/voxdefs.h   ← plus one small header per added voice (convention; see Topic 3)

Topic 3 — Install a new voice

Here's the main event. The recipe has four steps, and because the branch did the hard work already, none of them involves editing a single line inside the voice you're installing.

Files you'll touch

File

Repo

What you do

components/flite/src/lang/<voice>/

ButterBot-Firmware

Copy the voice's .c/.h files in — no edits

components/flite/include/lang/<voice>/voxdefs.h

ButterBot-Firmware

Park the voice's voxdefs.h here (convention)

main/src/Audio/SpeechGen.cpp

ButterBot-Firmware

Swap three registration lines

components/flite/CMakeLists.txt

ButterBot-Firmware

(Optional) Exclude the old voice from the build

Step 1 — Get a voice

Ready-made voices live in the flite project's source tree, in the lang/ directory: github.com/festvox/flite. Download the repository (the ZIP button is fine here — flite has no submodules) and look inside lang/. Three are 16 kHz ClusterGen voices ready for the robot:

Voice folder

Sounds like

Notes

cmu_us_slt

US English, female

This guide's worked example — grab this one first

cmu_us_rms

US English, male

Deep, calm

cmu_us_awb

Scottish English, male

A robot with a Scottish accent. You know you want this

Avoid cmu_us_kal (that's the 8 kHz cousin of the stock voice — wrong sample rate) and cmu_time_awb (a specialist voice that only tells the time). The truly adventurous can build a voice of their own with the FestVox tools — as long as the result is 16 kHz US English C sources, the robot will take it.

Step 2 — Copy the folder

Copy the whole lang/cmu_us_slt/ folder from the flite download into components/flite/src/lang/cmu_us_slt/. Then move its one odd file out: voxdefs.h goes to components/flite/include/lang/cmu_us_slt/voxdefs.h. (That header is part of flite's own build system and the robot's build never reads it — parking it under include/ just keeps the source folders tidy and every installed voice looking the same.)

That's the entire copy step. No renaming, no editing, no build-file surgery — the flite component's build automatically compiles every .c file under src/.

One command after copying: the build system's list of source files is cached, exactly like in Guide 5. After adding the new files, run idf.py reconfigure once, or the build will end with undefined reference to 'register_cmu_us_slt'. (Editing existing files never needs this.)

Step 3 — Swap three lines

The firmware picks its voice in one place: the top of SpeechGen.cpp. The register/unregister functions are always named after the voice's folder — register_cmu_us_slt for cmu_us_slt, and so on:

// FILE: ButterBot-Firmware/main/src/Audio/SpeechGen.cpp  (edits — three lines)// Before:
extern "C" {
cst_voice* register_cmu_us_kal16(const char* voxdir);
void unregister_cmu_us_kal16(cst_voice* voice);
}
// ...
    voice = register_cmu_us_kal16(nullptr);      // in the constructor
// ...
    unregister_cmu_us_kal16(voice);              // in the destructor// After:
extern "C" {
cst_voice* register_cmu_us_slt(const char* voxdir);
void unregister_cmu_us_slt(cst_voice* voice);
}
// ...
    voice = register_cmu_us_slt(nullptr);        // in the constructor
// ...
    unregister_cmu_us_slt(voice);                // in the destructor

(Installing cmu_us_rms or cmu_us_awb instead? The exact same three lines, with rms or awb in the names.)

Step 4 — Optional: evict the old voice from the build

The stock voice's data tables are enormous — around 18 MB of generated C — and once nothing references them the linker drops them from the robot's flash image anyway. But they still get compiled every build, which costs time. One line in the flite component's build file skips them:

// FILE: ButterBot-Firmware/components/flite/CMakeLists.txt  (edit — add the second line)
list(FILTER sources EXCLUDE REGEX "lex_data_include*")
list(FILTER sources EXCLUDE REGEX "lang/cmu_us_kal16/")   // new: skip compiling the unused stock voice

The sources stay on disk, so switching back later is just deleting the line. (If you ever exclude a voice that's still registered in SpeechGen.cpp, the build fails with undefined reference to 'register_cmu_us_kal16' — the fix is obvious in hindsight: don't evict the tenant who lives there.)

Step 5 — Build, flash, listen

  1. idf.py reconfigure (you added new files in Step 2).

  2. idf.py build, then flash (Guide 2, Steps 6 and 8).

  3. Short-press the power button and ask for a joke.

Two things you'll notice with a ClusterGen voice, both normal:

  • A short pause before each sentence. ClusterGen voices plan a whole sentence before the first sound comes out. Longer sentence, longer pause — one more reason the robot's phrases are written short.

  • A new line in the monitor. Run idf.py monitor and the firmware reports exactly how long that planning took, once per sentence, when debug logging for the SpeechGen tag is enabled: first audio chunk after 412 ms. If you're experimenting with tuning (Topic 4), this number is your stopwatch.

Topic 4 — Tune the new voice

ClusterGen voices respond to every knob from Topic 1 — duration_stretch, int_f0_target_mean, int_f0_target_stddev, f0_shift all work, with the voice's own recorded character as the baseline (pitch comes out clamped to a sane 50–700 Hz, so you can't push a voice into ultrasound by accident). But this family brings two knobs of its own, and both are about the same trade: sound quality versus CPU time.

If speech sounds slow-motion or choppy

The robot is telling you the vocoder can't keep up in real time. Two remedies, in order:

Remedy 1 — mlsa_speed_param (an integer!). It tells the vocoder to ignore the N subtlest ingredients of the sound. Values 5–15 are reasonable; each step is a little less CPU and a slightly duller, more muffled voice. Set it right next to your other feature lines:

// FILE: ButterBot-Firmware/main/src/Audio/SpeechGen.cpp  (edit, inside the SpeechGen constructor)
feat_set_int(voice->features, "mlsa_speed_param", 5);   // new: NOTE feat_set_int, not _float

(Tuning from the app side like this leaves the voice files completely untouched — which keeps your next voice swap as clean as the first one.)

Remedy 2 — drop the vocoder's precision order. A deeper cut, worth about 25% of the vocoder's CPU time; flite's authors ship this exact trick on phones and say it "sounds basically the same". In init_vocoder, change the 5 to a 4:

// FILE: ButterBot-Firmware/components/flite/src/cg/cst_mlsa.c  (edit, inside init_vocoder)
// Before:
    vs->pd   = 5;
// After:
    vs->pd   = 4;

If the voice is too quiet or too loud

Here is the volume knob Topic 1 promised. Every ClusterGen voice carries a gain value in its data tables — it's the last field of the big voice-description struct in the voice's ..._cg.c file. For our worked example:

// FILE: ButterBot-Firmware/components/flite/src/lang/cmu_us_slt/cmu_us_slt_cg.c  (excerpt, end of cmu_us_slt_cg_db)
  1.5 /* gain */

Raise it for louder, lower it for quieter — small steps (1.5 → 1.8) go a long way, and too high will distort. This is the one tuning change that lives in a voice file rather than SpeechGen.cpp, because the struct is read-only at runtime. It's also the exception that proves the rule: it's a number swap, not a code change.

Symptom checklist

Symptom

Fix

Speech drags, stutters, or sounds underwater

Raise mlsa_speed_param (5 → 10), then try pd = 4. Recheck after each change.

task_wdt: IDLE1 in the monitor while speaking

Same cause at a worse stage — synthesis far below real time. Same fixes, in the same order. (On master branch this is expected and unfixable — switch branches.)

Long silent pause before long sentences

Normal for ClusterGen (sentence-level planning). Keep phrases short; watch first audio chunk after N ms in the monitor to compare tuning.

Voice sounds dull or muffled after tuning

You traded too much quality for speed — lower mlsa_speed_param back toward 5, restore pd = 5.

Cheat sheet: where everything lives

I want to…

Edit these files

Change pitch, speed, or intonation

feat_set_float lines in the SpeechGen constructor, main/src/Audio/SpeechGen.cpp

Install a new voice

Copy into components/flite/src/lang/<voice>/ (+ voxdefs.hinclude/lang/<voice>/), swap 3 lines in SpeechGen.cpp, run idf.py reconfigure

Skip compiling an unused voice

One list(FILTER ...) line in components/flite/CMakeLists.txt

Make a ClusterGen voice faster / lighter

feat_set_int("mlsa_speed_param") in SpeechGen.cpp; then vs->pd in components/flite/src/cg/cst_mlsa.c

Change loudness (ClusterGen only)

The gain value at the end of components/flite/src/lang/<voice>/<voice>_cg.c

Measure the pre-speech pause

first audio chunk after N ms in idf.py monitor (debug log, SpeechGen tag)

Get the stock robot back

git checkout master + git submodule update --init --recursive, rebuild, reflash

Troubleshooting

Symptom

Fix

undefined reference to 'register_cmu_us_...' at link time

Either you copied new voice files and skipped idf.py reconfigure, or the voice name in SpeechGen.cpp doesn't match the folder name, or a list(FILTER ...) line is excluding the voice you're registering.

Robot crashes mid-sentence with task_wdt: IDLE1

You're on the master branch with a ClusterGen voice — switch to voice-modding. Already on it? The vocoder needs relief: Topic 4's speed remedies.

Voice sounds like a broken tape deck (wrong speed and pitch at once)

The voice isn't 16 kHz. Check its ..._cg.c for 16000; if it says 8000 or 48000, pick a different voice.

My feat_set_... tuning changes nothing

Type mismatch — the float knobs need feat_set_float (with f-suffixed values) and mlsa_speed_param needs feat_set_int. Wrong type = silently ignored.

Speech is intact but dull/muffled

Over-tuned for speed: lower mlsa_speed_param, restore pd = 5 (Topic 4).

git checkout voice-modding says the branch doesn't exist

Your clone predates the branch. Run git fetch origin first, then git checkout voice-modding.

Build works, robot speaks, but it's the old voice

You edited the extern "C" declarations but missed the register call in the constructor (or vice versa). All three lines in SpeechGen.cpp must name the same voice.

I want my stock robot back

Uncommitted experiments: git checkout -- . && git submodule foreach --recursive git checkout -- . Then git checkout master + submodule update, rebuild, and flash.

Your Butter Bot now speaks with whatever voice you gave it — and you've touched the deepest layer of the firmware the guides cover. Want an AI pair-programmer for your next voice experiment? Guide 3 — Claude Code and Guide 4 — OpenAI Codex show the workflow, and every section of this guide doubles as a spec you can paste straight into them.

Did this answer your question?