Lesson 12: Talking to BonicBot
Learning Objective
Build a complete, local voice assistant that lets you talk to BonicBot — combining offline speech-to-text (faster-whisper), a local language model (Ollama qwen3:0.6b), neural text-to-speech (Piper TTS), and synchronized arm/neck body language to create a responsive conversational AI robot.
Introduction
In this lesson, we bring everything together to create a full conversational experience: talking directly to BonicBot!
Instead of typing commands or relying on cloud web services, BonicBot listens, thinks, and speaks completely offline on your computer. When you speak to BonicBot:
- Hearing (STT): faster-whisper records audio from your microphone and converts your spoken voice into text locally with high accuracy, even for technical terminology or in noisy environments.
- Thinking (LLM) & Gesturing: A local language model running via Ollama (
qwen3:0.6b) formulates a response. While it thinks, background threads trigger spoken filler phrases and turn BonicBot’s neck in a curious thinking posture so the pause feels natural. - Speaking (TTS) & Posing: Piper TTS synthesizes a natural neural voice, streaming audio out in real time through PyAudio while BonicBot moves its arms into an explaining pose.
BonicBot’s full conversational pipeline looks like this:
microphone -> faster-whisper (STT) -> Ollama (LLM) & physical gestures -> Piper (TTS)By connecting speech recognition, local AI reasoning, neural speech synthesis, and physical body language, BonicBot becomes a truly interactive robotic conversational partner.
Setup: Installing Packages
Before running the code, make sure your computer has the required Python packages and system libraries installed. Open a terminal and run:
pip install bonicbot-bridge opencv-python numpy ollama speechrecognition piper-tts faster-whisper pyaudioWhat each package / tool does
| Package / Tool | Purpose |
|---|---|
bonicbot-bridge | The official BonicBot SDK. Provides the BonicBot class used to connect to the robot, control its arms (bot.move_left_arm(), bot.move_right_arm()), and move its neck (bot.look_left(), bot.look_right(), bot.look_center()). |
faster-whisper | Highly optimized local Speech-to-Text (STT) library based on CTranslate2. Used by listen() to transcribe microphone audio offline with high accuracy. |
piper-tts | Fast, local neural Text-to-Speech (TTS) engine (PiperVoice, SynthesisConfig). Used by speak() to generate natural-sounding voice audio. |
ollama | Python client for Ollama. Used by ask_llm() to send conversation history to the local qwen3:0.6b model offline. |
speechrecognition | Imported as sr. Handles microphone audio recording, noise calibration (adjust_for_ambient_noise), and energy threshold calculation. |
pyaudio | Low-level audio library. Streams raw PCM audio chunks generated by Piper TTS directly to system speakers. |
numpy | Imported as np. Used for audio buffer manipulation, microphone raw data conversion, and dummy audio synthesis for Whisper model warmup. |
If pip install fails, try pip3 install ... instead, or use a virtual environment:
python3 -m venv bonicbot-env
source bonicbot-env/bin/activate # On Windows: bonicbot-env\Scripts\activate
pip install bonicbot-bridge opencv-python numpy ollama speechrecognition piper-tts faster-whisper pyaudioHow to run the program
- Install Ollama (one-time setup):
- Windows / Mac: Download and run the installer from ollama.com/download .
- Linux: Run
curl -fsSL https://ollama.com/install.sh | shin your terminal.
- Install PortAudio (system dependency for
pyaudio):- Linux:
sudo apt install portaudio19-dev - macOS:
brew install portaudio
- Linux:
- Pull the LLM model:
ollama pull qwen3:0.6b - Find your BonicBot’s IP address — if using physical hardware, update the
BonicBot()connection line. - Save the code into a file, e.g.,
lesson12_talking_to_bonicbot.py. - Run the script:
python lesson12_talking_to_bonicbot.py - Watch the terminal logs as Ollama, Piper voice, and faster-whisper load and warm up.
- Stay quiet for 2 seconds while the microphone calibrates for ambient room noise.
- Speak your question into the microphone (e.g. “What is artificial intelligence?”).
- BonicBot will speak a filler phrase while turning its neck in a thinking motion, query the local LLM, push its arms forward in an explaining pose, and speak the answer aloud.
- Say “goodbye” or press Ctrl+C to exit.
Don’t have a physical BonicBot? Try it in simulation (optional)
If you don’t have physical access to a BonicBot, you can still work through this lesson using the ROS 2 simulation environment:
-
Launch the BonicBot simulation:
ros2 launch my_bot robot_system.launch.py use_sim_time:=true world:=obsworld.sdf use_real_camera:=True -
When
GESTURES_ENABLED = True, the script connects toBonicBot()onlocalhost, moving the simulated robot’s arms and neck while executing speech recognition, local LLM generation, and Piper TTS audio on your computer. -
Everything else in the voice and gesture pipeline works identically.
Code
Click to view the complete program
import difflib
import random
import re
import shutil
import subprocess
import sys
import threading
import time
from pathlib import Path
import numpy as np
import ollama
import pyaudio
import speech_recognition as sr
from faster_whisper import WhisperModel
from piper import PiperVoice, SynthesisConfig
from piper.download_voices import download_voice
# NOTE: this assumes your BonicBot control class lives here and exposes
# move_left_arm() / move_right_arm() / set_neck() / look_left() / etc. as
# described. Swap this import (and the `bot = BonicBot()` line in main())
# for whatever actually creates your `bot` object if it's different.
from bonicbot_bridge import BonicBot
# ============================================================================
# CONFIG
# ============================================================================
OLLAMA_MODEL = "qwen3:0.6b" # small (~400MB), fast on CPU, good for a first LLM
HOST = "[IP_ADDRESS]"
PIPER_VOICE = "en_US-lessac-medium" # ~60MB, natural-sounding, good default
# Other voices worth trying (just change the string above, everything else
# auto-downloads the new one). Listen before you pick:
# https://rhasspy.github.io/piper-samples/
# "en_US-amy-medium" - US English, female
# "en_GB-alba-medium" - British English, female
# "en_US-lessac-low" - smaller & faster to synthesize, more robotic
PIPER_VOICES_DIR = Path(__file__).resolve().parent / "piper_voices"
PIPER_LENGTH_SCALE = 0.8 # > 1 = slower speech, < 1 = faster (1.0 = voice's own pace)
PIPER_VOLUME = 1.0 # > 1 = louder, < 1 = quieter
PIPER_USE_CUDA = False # set True if you have an NVIDIA GPU + onnxruntime-gpu
WHISPER_MODEL_SIZE = "base.en" # tiny.en (fastest) / base.en (balanced) / small.en (most accurate, slower)
WHISPER_DEVICE = "cpu"
WHISPER_COMPUTE_TYPE = "int8"
WHISPER_SAMPLE_RATE = 16000 # matches what Whisper expects
WHISPER_BEAM_SIZE = 1 # 1 = greedy decoding, noticeably faster than beam search;
# bump back up to 3-5 only if you start seeing more misheard words than you can live with
SERVER_START_TIMEOUT = 15.0 # seconds to wait for the Ollama server to come up
SERVER_POLL_INTERVAL = 0.5
LISTEN_TIMEOUT = 60.0 # seconds to wait for the person to start speaking
PHRASE_TIME_LIMIT = 30.0 # max seconds for a single thing they say
MAX_HISTORY_MESSAGES = 13 # system message + ~6 back-and-forth exchanges
PAUSE_THRESHOLD = 1.2 # seconds of silence before a phrase is considered done —
# this is the biggest lever for perceived lag: every turn waits this long
# after you stop talking before transcription even starts. Lower = snappier
# but risks cutting someone off if they pause mid-sentence to think; 0.8-1.5
# is a reasonable range to try. The old 3.0 meant 3 full seconds of dead air
# on every single turn.
NON_SPEAKING_DURATION = 0.5 # silence padding kept on either side of speech
DYNAMIC_ENERGY_THRESHOLD = True # auto-adapt to room noise instead of a fixed cutoff
AMBIENT_NOISE_CALIBRATION_DURATION = 2.0
REPEAT_SIMILARITY_THRESHOLD = 0.85
EXIT_WORDS = {"goodbye", "bye", "stop", "exit", "quit"}
# ----------------------------------------------------------------------
# GESTURE CONFIG
# ----------------------------------------------------------------------
GESTURES_ENABLED = True # set False to run with no robot body attached
# Left/right arm positions, in degrees, within the joint limits you gave:
# shoulder pitch -45–180, elbow 0–50
ARM_REST_SHOULDER = 0 # resting position for both arms
ARM_REST_ELBOW = 0
# "Explaining" pose: both arms move here, once, and HOLD for the whole
# reply while BonicBot talks — no elbow waving. (The elbow servo doesn't
# track commanded positions on this rig right now — see diagnostic notes —
# so it's been dropped rather than animate something that doesn't move.)
ARM_FORWARD_SHOULDER = 40
ARM_FORWARD_ELBOW = 20
THINKING_GESTURE_PAUSE = 0.7 # seconds between each left/right neck turn
# Said out loud (briefly) while the LLM works in the background, so the
# pause doesn't feel like dead air. Kept short since it's just filler.
THINKING_PHRASES = [
"Hmm, let me think about that.",
"Good question, thinking...",
"Let me see.",
"One moment, thinking it over.",
]
SYSTEM_PROMPT = (
"You are BonicBot, a friendly voice assistant running on a small, "
"lightweight local AI model. You are good at simple things: basic facts, "
"definitions, simple math, and short explanations of AI/robotics/NLP "
"concepts a beginner would ask. You are NOT good at long stories, complex "
"multi-step reasoning, coding, or detailed technical questions — if asked "
"something like that, briefly say it's a bit much for you and suggest the "
"student ask something simpler instead. Keep answers short — one to three "
"sentences, spoken out loud. Use simple words. If you don't know something, "
"say so honestly instead of guessing. Never pretend to be a human, and "
"never give unsafe, harmful, or inappropriate advice."
)
EMOJI_PATTERN = re.compile(
"["
"\U0001F300-\U0001FAFF"
"\U00002600-\U000027BF"
"\U0001F1E6-\U0001F1FF"
"\U0001F900-\U0001F9FF"
"\U00002190-\U000021FF"
"\U00002B00-\U00002BFF"
"]+",
flags=re.UNICODE,
)
def clean_for_speech(text):
"""Strip markdown formatting and emoji before handing text to Piper —
the model doesn't know it's writing for a voice, so it still reaches
for **bold**, `code`, and emoji out of habit."""
text = EMOJI_PATTERN.sub("", text)
text = re.sub(r"\*\*(.*?)\*\*", r"\1", text) # **bold**
text = re.sub(r"\*(.*?)\*", r"\1", text) # *italic*
text = re.sub(r"`(.*?)`", r"\1", text) # `code`
text = re.sub(r"#+\s*", "", text) # headers
text = re.sub(r"[*_~]", "", text) # stray leftover symbols
text = re.sub(r"\s+", " ", text).strip()
return text
# ============================================================================
# OLLAMA SETUP — all handled from Python, no manual `ollama` commands
# ============================================================================
def ensure_ollama_installed():
"""Confirm the Ollama app is installed. This is the one thing pip can't
do — Ollama is a background service, not a Python package."""
if shutil.which("ollama") is None:
print("⚠️ The Ollama app isn't installed on this computer yet.")
print("This is a one-time setup step, done once per computer:")
print(" 1. Go to https://ollama.com/download")
print(" 2. Download and run the installer for your OS")
print(" 3. Re-run this script afterwards")
sys.exit(1)
def ensure_ollama_running():
"""Make sure the Ollama server is up. If it's not, start it as a
background process from Python — the student never types `ollama serve`."""
try:
ollama.list()
return
except Exception:
pass
print("Ollama server isn't running yet — starting it now...")
subprocess.Popen(
["ollama", "serve"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
start = time.time()
while time.time() - start < SERVER_START_TIMEOUT:
try:
ollama.list()
print("✅ Ollama server is up.")
return
except Exception:
time.sleep(SERVER_POLL_INTERVAL)
print("⚠️ Couldn't confirm the server started in time — continuing anyway.")
def is_model_available(model_name):
"""Return True if the model has already been downloaded."""
try:
ollama.show(model_name)
return True
except Exception:
return False
def ensure_model_pulled(model_name):
"""Download the model via the Python client if it isn't already present.
Same job as typing `ollama pull <model>` in a terminal, just from code."""
if is_model_available(model_name):
print(f"✅ Model '{model_name}' is already downloaded.")
return
print(
f"Model '{model_name}' not found locally — downloading now "
f"(one-time, a few hundred MB)..."
)
last_status = None
for progress in ollama.pull(model_name, stream=True):
status = progress.get("status", "")
if status and status != last_status:
print(f" {status}")
last_status = status
print(f"✅ Model '{model_name}' ready.")
# ============================================================================
# PIPER VOICE SETUP — auto-downloads the voice, no manual `piper` commands
# ============================================================================
def ensure_piper_voice_ready(voice_name, voices_dir):
"""Download the Piper voice (an .onnx model + matching .onnx.json config
file) if it isn't on disk yet, and return their paths. Mirrors
ensure_model_pulled() above — same "the student never types a download
command" philosophy, just for the TTS voice instead of the LLM."""
voices_dir.mkdir(parents=True, exist_ok=True)
model_path = voices_dir / f"{voice_name}.onnx"
config_path = voices_dir / f"{voice_name}.onnx.json"
if model_path.exists() and config_path.exists():
print(f"✅ Voice '{voice_name}' is already downloaded.")
return model_path, config_path
print(
f"Voice '{voice_name}' not found locally — downloading now "
f"(one-time, roughly 60MB)..."
)
try:
download_voice(voice_name, voices_dir)
except Exception as e:
print(f"⚠️ Couldn't download the Piper voice: {e}")
print("Check your internet connection and the voice name, then try again.")
sys.exit(1)
print(f"✅ Voice '{voice_name}' ready.")
return model_path, config_path
# ============================================================================
# TEXT-TO-SPEECH (Piper)
# ============================================================================
def speak(piper_voice, pyaudio_instance, text):
"""Synthesize text with Piper and stream it out through PyAudio."""
print(f"BonicBot: {text}")
speech_text = clean_for_speech(text)
syn_config = SynthesisConfig(length_scale=PIPER_LENGTH_SCALE, volume=PIPER_VOLUME)
stream = None
try:
for chunk in piper_voice.synthesize(speech_text, syn_config=syn_config):
if stream is None:
stream = pyaudio_instance.open(
format=pyaudio_instance.get_format_from_width(chunk.sample_width),
channels=chunk.sample_channels,
rate=chunk.sample_rate,
output=True,
)
stream.write(chunk.audio_int16_bytes)
finally:
if stream is not None:
stream.stop_stream()
stream.close()
# ============================================================================
# SPEECH-TO-TEXT (faster-whisper, fully offline)
# ============================================================================
def warm_up_whisper(whisper_model):
"""Run one throwaway transcription on a second of silence right after
loading the model. The very first real transcribe() call in a session
is noticeably slower than the rest (kernels/caches aren't warm yet) —
this eats that one-time cost during startup instead of during the
student's first question."""
dummy_audio = np.zeros(WHISPER_SAMPLE_RATE, dtype=np.float32)
segments, _ = whisper_model.transcribe(dummy_audio, language="en", beam_size=WHISPER_BEAM_SIZE)
list(segments) # force evaluation, segments is a lazy generator
def listen(recognizer, mic, whisper_model):
"""Record one utterance from the microphone and transcribe it locally
with Whisper. Returns the text, or None if nothing usable was heard."""
with mic as source:
print("\n🎤 Listening... (say 'goodbye' to quit)")
try:
audio = recognizer.listen(
source, timeout=LISTEN_TIMEOUT, phrase_time_limit=PHRASE_TIME_LIMIT
)
except sr.WaitTimeoutError:
return None
audio_np = np.frombuffer(audio.get_raw_data(), dtype=np.int16).astype(np.float32) / 32768.0
segments, _ = whisper_model.transcribe(
audio_np,
language="en",
beam_size=WHISPER_BEAM_SIZE,
condition_on_previous_text=False, # each utterance here is a fresh,
# standalone recording, not a continuation of the last one — this
# skips Whisper carrying context forward between turns, which is
# unnecessary work here and occasionally causes it to loop/hallucinate
)
text = " ".join(seg.text for seg in segments).strip()
if not text:
return None
print(f"You said: {text}")
return text
# ============================================================================
# LOCAL LLM (via Ollama)
# ============================================================================
def ask_llm(history):
"""Send the running conversation to the local model and return its reply.
Auto-pulls the model if Ollama reports it's missing (belt-and-suspenders,
in case ensure_model_pulled() was somehow skipped)."""
try:
response = ollama.chat(model=OLLAMA_MODEL, messages=history, think=False)
except ollama.ResponseError as e:
if e.status_code == 404:
ensure_model_pulled(OLLAMA_MODEL)
response = ollama.chat(model=OLLAMA_MODEL, messages=history, think=False)
else:
raise
return response["message"]["content"].strip()
def trim_history(history):
"""Keep the conversation short by retaining the system prompt plus
the most recent exchanges."""
if len(history) > MAX_HISTORY_MESSAGES:
history = [history[0]] + history[-(MAX_HISTORY_MESSAGES - 1):]
return history
def is_repeat_question(current_text, previous_text):
"""Detect whether the current question is essentially a re-ask of the
previous one, so the LLM can be nudged to try a different answer."""
if not previous_text:
return False
a, b = current_text.strip().lower(), previous_text.strip().lower()
return difflib.SequenceMatcher(None, a, b).ratio() >= REPEAT_SIMILARITY_THRESHOLD
# ============================================================================
# ROBOT GESTURES
# ============================================================================
def thinking_gesture_loop(bot, stop_event):
"""Turn the neck left and right in a slow loop until stop_event is set —
this is what plays while the LLM is generating its reply in the
background. Runs on its own thread; always leaves the neck centered
when it exits, even if something above it goes wrong."""
if not GESTURES_ENABLED:
return
try:
while not stop_event.is_set():
bot.look_left()
if stop_event.wait(THINKING_GESTURE_PAUSE):
break
bot.look_right()
if stop_event.wait(THINKING_GESTURE_PAUSE):
break
except Exception as e:
print(f"⚠️ Thinking gesture skipped: {e}")
finally:
try:
bot.look_center()
except Exception as e:
print(f"⚠️ Couldn't re-center neck: {e}")
def reset_arms_to_rest(bot):
"""Move both arms back to their resting position."""
if not GESTURES_ENABLED:
return
try:
bot.move_left_arm(ARM_REST_SHOULDER, ARM_REST_ELBOW, wait=False)
bot.move_right_arm(ARM_REST_SHOULDER, ARM_REST_ELBOW, wait=True)
except Exception as e:
print(f"⚠️ Couldn't reset arms to rest: {e}")
def center_head_for_response(bot):
"""Center the head — done right before BonicBot starts speaking."""
if not GESTURES_ENABLED:
return
try:
bot.look_center()
except Exception as e:
print(f"⚠️ Couldn't center head: {e}")
def push_arms_forward(bot):
"""Move both arms into the 'explaining' pose (shoulders forward, elbows
bent) once, and just hold it there — no waving. Called synchronously,
blocking, right before speak() starts."""
if not GESTURES_ENABLED:
return
try:
bot.move_left_arm(ARM_FORWARD_SHOULDER, ARM_FORWARD_ELBOW, wait=False)
bot.move_right_arm(ARM_FORWARD_SHOULDER, ARM_FORWARD_ELBOW, wait=True)
except Exception as e:
print(f"⚠️ Couldn't push arms forward: {e}")
def ask_llm_in_background(history, result):
"""Run ask_llm() on a worker thread and stash the outcome in `result`
(a plain dict) so the main thread can pick it up once ready — this is
what lets the thinking gesture and filler phrase play concurrently with
the actual LLM call instead of after it."""
try:
result["reply"] = ask_llm(history)
except Exception as e:
result["error"] = e
# ============================================================================
# MAIN LOOP
# ============================================================================
def main():
ensure_ollama_installed()
ensure_ollama_running()
ensure_model_pulled(OLLAMA_MODEL)
model_path, config_path = ensure_piper_voice_ready(PIPER_VOICE, PIPER_VOICES_DIR)
print("Loading Piper voice into memory...")
piper_voice = PiperVoice.load(model_path, config_path=config_path, use_cuda=PIPER_USE_CUDA)
pyaudio_instance = pyaudio.PyAudio()
print("Loading Whisper model into memory...")
whisper_model = WhisperModel(
WHISPER_MODEL_SIZE, device=WHISPER_DEVICE, compute_type=WHISPER_COMPUTE_TYPE
)
print("Warming up Whisper (one-time cost, keeps your first question fast)...")
warm_up_whisper(whisper_model)
bot = BonicBot(host=HOST) if GESTURES_ENABLED else None
if bot is not None:
reset_arms_to_rest(bot) # known starting position
try:
bot.look_center()
except Exception as e:
print(f"⚠️ Couldn't center neck on startup: {e}")
recognizer = sr.Recognizer()
recognizer.pause_threshold = PAUSE_THRESHOLD
recognizer.non_speaking_duration = NON_SPEAKING_DURATION
recognizer.dynamic_energy_threshold = DYNAMIC_ENERGY_THRESHOLD
mic = sr.Microphone(sample_rate=WHISPER_SAMPLE_RATE)
history = [{"role": "system", "content": SYSTEM_PROMPT}]
last_user_text = None
print("Calibrating microphone for background noise, please stay quiet...")
with mic as source:
recognizer.adjust_for_ambient_noise(source, duration=AMBIENT_NOISE_CALIBRATION_DURATION)
speak(
piper_voice,
pyaudio_instance,
"Hello! I'm BonicBot, a small local voice assistant. I'm best at "
"simple stuff, like quick facts, easy definitions, or short "
"questions about AI and robotics. Try me with something short! "
)
try:
while True:
user_text = listen(recognizer, mic, whisper_model)
if user_text is None:
continue
if user_text.strip().lower() in EXIT_WORDS:
speak(piper_voice, pyaudio_instance, "Goodbye! It was nice talking with you.")
break
if is_repeat_question(user_text, last_user_text):
turn_content = (
f"{user_text}\n\n"
"[The user just asked this again — your previous answer was "
"likely wrong, unclear, or not what they needed. Do not repeat "
"it. If you're not confident of the correct answer, say so "
"honestly instead of guessing again.]"
)
else:
turn_content = user_text
history.append({"role": "user", "content": turn_content})
last_user_text = user_text
print("Thinking...")
# Kick off the neck "thinking" gesture and the actual LLM call
# on their own threads so they run at the same time...
stop_thinking = threading.Event()
gesture_thread = threading.Thread(
target=thinking_gesture_loop, args=(bot, stop_thinking), daemon=True
)
gesture_thread.start()
llm_result = {}
llm_thread = threading.Thread(
target=ask_llm_in_background, args=(history, llm_result), daemon=True
)
llm_thread.start()
# ...and say a short filler out loud while both of those run in
# the background, so the wait doesn't feel like dead air.
speak(piper_voice, pyaudio_instance, random.choice(THINKING_PHRASES))
llm_thread.join()
stop_thinking.set()
gesture_thread.join()
if "error" in llm_result:
raise llm_result["error"]
reply = llm_result["reply"]
history.append({"role": "assistant", "content": reply})
history = trim_history(history)
center_head_for_response(bot)
push_arms_forward(bot)
speak(piper_voice, pyaudio_instance, reply)
reset_arms_to_rest(bot)
except KeyboardInterrupt:
print("\nShutting down...")
finally:
pyaudio_instance.terminate()
if __name__ == "__main__":
main()Before running the program, make sure the two non-Python pieces are in place (one-time setup):
- Ollama: download and run the installer from ollama.com/download (Windows/Mac), or run
curl -fsSL https://ollama.com/install.sh | shon Linux. - PortAudio (needed by PyAudio to record and play sound): Mac users run
brew install portaudio; Linux users runsudo apt install portaudio19-dev. Windows usually needs nothing extra.
Also install the required Python packages before running:
pip install ollama SpeechRecognition piper-tts pyaudio faster-whisper numpy --break-system-packagesThe first time the script runs, it will also download the Piper voice file (~60MB) into a piper_voices folder next to the script, and download the Whisper speech-to-text model via faster-whisper — both only happen once.
Code Walkthrough
Line-by-line explanation
- Imports & Configuration (
lines 39–231) — Importsollama,pyaudio,speech_recognition,faster_whisper,piper, andBonicBot. Defines system parameters:OLLAMA_MODEL = "qwen3:0.6b",PIPER_VOICE = "en_US-lessac-medium",WHISPER_MODEL_SIZE = "base.en",SYSTEM_PROMPT, and arm gesture positions (ARM_REST_SHOULDER,ARM_FORWARD_SHOULDER, etc.). clean_for_speech(text)(lines 234–245) — Uses regex to strip markdown formatting (**bold**,*italic*,`code`) and emoji characters from LLM responses so Piper TTS reads plain natural text.- Ollama Management (
lines 251–318) —ensure_ollama_installed()verifies the binary exists;ensure_ollama_running()startsollama servein the background if not active;ensure_model_pulled()pullsqwen3:0.6bautomatically if not present. - Piper Voice Setup & Audio Output (
lines 323–375) —ensure_piper_voice_ready()downloads requested ONNX voice models.speak()synthesizes text chunks viapiper_voice.synthesize()and streams raw audio directly to system speakers usingpyaudio. - Offline Speech-to-Text with Whisper (
lines 380–419) —warm_up_whisper()runs a one-time dummy inference to eliminate first-call latency.listen()records microphone input viaspeech_recognitionand transcribes raw PCM audio offline usingfaster_whisper.WhisperModel. - LLM Context & Repetition Protection (
lines 424–454) —ask_llm()sends context history to Ollama.trim_history()caps message history length.is_repeat_question()compares current input against previous questions usingdifflib.SequenceMatcherto prevent repetitive LLM loops. - Robot Gestures & Multithreading (
lines 459–526) —thinking_gesture_loop()slowly turns BonicBot’s neck left and right during thinking.push_arms_forward()moves arms into an explaining posture.ask_llm_in_background()runs the LLM query on a worker thread concurrent with filler speech and neck gestures. - Main Loop (
lines 531–647) — Initializes Ollama, Piper, faster-whisper, and BonicBot; calibrates ambient microphone noise; enters continuous conversation loop (listening, triggering filler speech + neck movements on background threads, fetching LLM reply, posing arms, speaking reply, and resetting arms).
Expected Output
Click to see expected output
Visual Output:
📌 Note: This visual demonstration is using the ROS 2 simulation with use_real_camera:=True.
Terminal Output:
✅ Model 'qwen3:0.6b' is already downloaded.
✅ Voice 'en_US-lessac-medium' is already downloaded.
Loading Piper voice into memory...
Loading Whisper model into memory...
Warming up Whisper (one-time cost, keeps your first question fast)...
Calibrating microphone for background noise, please stay quiet...
BonicBot: Hello! I'm BonicBot, a small local voice assistant. I'm best at simple stuff, like quick facts, easy definitions, or short questions about AI and robotics. Try me with something short!
🎤 Listening... (say 'goodbye' to quit)
You said: what is natural language processing
Thinking...
BonicBot: Hmm, let me think about that.
BonicBot: Natural language processing, or NLP, is how computers understand and work with human language, like text or speech.
🎤 Listening... (say 'goodbye' to quit)
You said: goodbye
BonicBot: Goodbye! It was nice talking with you.The console output shows BonicBot’s voice loading and warming up, and it now prints the spoken filler phrase while thinking. BonicBot understands what’s said more reliably, and physically reacts with body gestures.
🔧 Under the Hood
Why does swapping a couple of functions change how BonicBot sounds — and how well it listens — so much?
Both ends of the pipeline changed for the same underlying reason: they went from a general-purpose, black-box service to a small neural model you’re running yourself.
Listening: recognize_google sends your audio to a web API and gets back whatever that service decides the words were — you can’t inspect it, tune it, or use it without internet. faster-whisper is Whisper’s speech recognition model running locally, on your own CPU, trained on a huge and varied set of real speech. That’s why it holds up better on technical vocabulary and background noise — it isn’t a general dictation tool, it’s a model you can point at and reason about.
Speaking: the old pyttsx3 voice doesn’t generate speech itself — it just hands text to a voice that’s already installed on the operating system. That voice was built using older rule-based or formant-synthesis techniques, which is part of why it sounds mechanical and why it’s a different voice on every computer. Piper is a small neural text-to-speech model. Instead of following hand-written pronunciation rules, it was trained on real recorded human speech and learned to generate new audio that sounds like a real voice. Because the voice is a downloaded model file (.onnx) rather than something baked into the operating system, it sounds exactly the same on every student’s computer, Mac or Windows or Linux.
Notice that speak() streams audio one sentence at a time instead of waiting for the whole reply:
for chunk in piper_voice.synthesize(speech_text, syn_config=syn_config):
...
stream.write(chunk.audio_int16_bytes)This means BonicBot can start talking as soon as the first sentence is ready, instead of pausing awkwardly while the rest of a long reply is still being generated.
Concurrency & Gestures: Because calling the local LLM takes a few seconds, the script runs ask_llm_in_background() and thinking_gesture_loop() on separate background threads. This lets BonicBot speak a short filler phrase from THINKING_PHRASES and slowly turn its neck left and right in a “thinking” pose while the LLM generates a reply. Once the reply is ready, the threads finish, the head re-centers, the arms move forward to “explain,” and BonicBot speaks the actual response before resetting its arms to a resting position.
Student Challenge
Try giving BonicBot a different personality just by changing its voice, or a different listening/gesture behavior.
For example:
- Swap
PIPER_VOICEto"en_GB-alba-medium"for a British-accented BonicBot. - Try
"en_US-lessac-low"and compare how it sounds and how much faster it loads. - Adjust
PIPER_LENGTH_SCALEto make BonicBot speak slower (a value above 1.0) or faster (a value below 1.0). - Change
WHISPER_MODEL_SIZEfrom"base.en"to"tiny.en"and notice how much faster transcription becomes. - Edit
THINKING_PHRASESor adjustTHINKING_GESTURE_PAUSEto change how BonicBot acts during its thinking phase.
Listen to voice samples first at the Piper samples page before picking one, so you know roughly what to expect.
Hint
Only one line needs to change to try a new voice — everything else in the script downloads and loads it automatically:
PIPER_VOICE = "en_GB-alba-medium"The same is true for the listening model:
WHISPER_MODEL_SIZE = "tiny.en"Reflection Question
BonicBot’s old setup (recognize_google for listening, pyttsx3 for speaking) needed no download and started working instantly, while the new setup (faster-whisper and Piper) needs a one-time download and more memory to run, but listens more accurately and sounds much more natural. What kinds of situations might make the old, lighter-weight approach the better choice, even though it’s less accurate and sounds more robotic?