This tutorial guides you through implementing complete text-to-speech (TTS) capabilities using an external USB audio device in the Uno Q onboard Linux environment. The guide covers two modes:
mpg123 decoder to stream and play MP3 audio in real time. It delivers smooth, natural voice quality without requiring the large ffmpeg framework.audio_int16_bytes streams directly to the ALSA driver (aplay), it eliminates the several-second cold-start delay from reloading models and avoids sentence truncation.The entire workflow runs in a headless native Linux terminal without requiring a desktop graphical interface.
Development Tip: We recommend connecting to the Uno Q remotely via SSH from your computer for streamlined development and debugging. Ensure your external USB audio device is plugged into the onboard native USB-A port of the Uno Q.
List available sound cards in your Uno Q terminal:
cat /proc/asound/cards
The terminal typically prints output similar to the following:
0 [OnBoard ]: Dummy - OnBoard Dummy Soundcard
1 [Device ]: USB-Audio - USB Audio Device
card 1): In Linux, USB device replugging or system reboots can shift device index numbers between 0, 1, and 2.USB-Audio and note the string inside brackets [...]: For example, Device in the sample output above.plughw:CARD=Device,DEV=0. The value assigned to CARD= matches this bracketed identifier (if your hardware shows Audio, change it to CARD=Audio), establishing a stable, persistent hardware binding.You can further confirm that the USB device is recognized as an active playback endpoint:
aplay -l
If the list includes an entry such as card X: Device [USB Audio Device], device 0: USB Audio [USB Audio], the driver is loaded properly and the playback subdevice index is 0.
mkdir -p /home/arduino/music_player
cd /home/arduino/music_player
python3 -m venv venv
source venv/bin/activate
Online mode streams neural-synthesized audio from the cloud and pipes MP3 data directly into mpg123 to drive the speaker with low latency.
Run the following commands inside your activated virtual environment:
pip install edge-tts
sudo apt update
sudo apt install -y alsa-utils mpg123
edge-tts: An asynchronous Python library for communicating with Microsoft Edge cloud TTS interfaces.mpg123: A lightweight command-line MP3 streaming decoder and player that receives raw audio bytes via pipes, avoiding disk I/O and transcoding overhead.You can write the script on your local computer and upload it via SCP, or create it directly on the Uno Q:
nano tts.py
Run this command from your computer terminal (CMD / PowerShell / Terminal):
scp tts.py arduino@<Uno_Q_IP>:/home/arduino/music_player/
Complete source code:
import asyncio
import subprocess
import edge_tts
VOICE = "zh-CN-XiaoxiaoNeural"
# Explicitly specify the sound card device to guarantee playback through the USB speaker
PCM_DEVICE = "plughw:CARD=Device,DEV=0"
async def speak(text: str, voice: str = VOICE):
communicate = edge_tts.Communicate(text, voice)
# Launch mpg123 for stream decoding; the -a flag directs audio to the USB hardware node
player = subprocess.Popen(
["mpg123", "-a", PCM_DEVICE, "-q", "-"],
stdin=subprocess.PIPE
)
try:
async for chunk in communicate.stream():
if chunk["type"] == "audio":
player.stdin.write(chunk["data"])
except BrokenPipeError:
pass
finally:
if player.stdin and not player.stdin.closed:
player.stdin.close()
player.wait()
if __name__ == "__main__":
text = "hello ,where are you from?"
asyncio.run(speak(text))
In the nano editor, press Ctrl + O and Enter to save, then press Ctrl + X to exit.
VOICE = "zh-CN-XiaoxiaoNeural"
zh-CN-XiaoxiaoNeural. For standard English output, replace it with an English persona (such as en-US-AriaNeural).communicate = edge_tts.Communicate(text, voice)
player = subprocess.Popen(
["mpg123", "-a", PCM_DEVICE, "-q", "-"],
stdin=subprocess.PIPE
)
mpg123 command-line player in a non-blocking background process.mpg123: A native C-based decoder optimized for MP3 playback.-a plughw:CARD=Device,DEV=0: Binds audio output to the physical USB sound card.-q (quiet): Suppresses terminal output, preventing console logging overhead on the embedded CPU.- (standard input): Instructs the player to stream directly from stdin rather than looking for a local file.stdin=subprocess.PIPE: Creates an anonymous system pipe allowing Python to write audio bytes directly to the decoder.try:
async for chunk in communicate.stream():
if chunk["type"] == "audio":
player.stdin.write(chunk["data"])
except BrokenPipeError:
pass
communicate.stream() yields audio chunks asynchronously as network packets arrive.chunk["type"] == "audio" filters out metadata packets, streaming pure MP3 payload directly to mpg123.BrokenPipeError prevents runtime crashes if the player process terminates unexpectedly.finally:
if player.stdin and not player.stdin.closed:
player.stdin.close()
player.wait()
player.stdin.close(): Sends an End-Of-File (EOF) marker to notify mpg123 that transmission has completed.player.wait(): Critical for preventing truncation. Pushing data into the pipe only means it left Python memory, not that the DAC has finished acoustic output. wait() blocks until the hardware FIFO/DMA buffer drains, ensuring trailing words are not cut short.python3 tts.py
"hello ,where are you from?".Offline mode operates on local ONNX neural network models, running fully independent of network access. To overcome the multi-second graph reloading penalty on low-power architectures (Cortex-A53), this implementation loads the model once into RAM during class initialization, then streams native audio_int16_bytes directly to ALSA.
piper-tts:source ~/music_player/venv/bin/activate
pip install piper-tts
mkdir -p ~/tts_en_offline
ls -lh ~/tts_en_offline/en_US-lessac-low.onnx
ls -lh ~/tts_en_offline/en_US-lessac-low.onnx.json
(Note: Both the .onnx computational graph and its matching .onnx.json phoneme map are required).
Create the script file on the Uno Q:
nano fast_tts_offline.py
Alternatively, write it on your computer and upload via SCP:
scp fast_tts_offline.py arduino@<Uno_Q_IP>:/home/arduino/music_player/
Complete source code:
import os
import subprocess
from piper.voice import PiperVoice
MODEL_PATH = os.path.expanduser("~/tts_en_offline/en_US-lessac-low.onnx")
CONFIG_PATH = os.path.expanduser("~/tts_en_offline/en_US-lessac-low.onnx.json")
PCM_DEVICE = "plughw:CARD=Device,DEV=0"
class FastOfflineTTS:
def __init__(self, model_path=MODEL_PATH, config_path=CONFIG_PATH, pcm_device=PCM_DEVICE):
self.pcm_device = pcm_device
print("[TTS] Loading model into memory, please wait (runs once)...")
# 1. Load model into RAM once to eliminate the 5-6s cold-start penalty per call
self.voice = PiperVoice.load(model_path, config_path=config_path)
self.sample_rate = str(self.voice.config.sample_rate)
print(f"[TTS] Model loaded successfully! Sample rate: {self.sample_rate}Hz.")
def speak(self, text: str):
text = text.strip()
if not text:
return
print(f"[TTS] Synthesizing: {text}")
# 2. Launch aplay to receive raw PCM streams (Mono, 16000Hz, 16-bit depth)
aplay_proc = subprocess.Popen(
[
"aplay",
"-D", self.pcm_device,
"-r", self.sample_rate,
"-f", "S16_LE",
"-t", "raw",
"-c", "1",
"-q"
],
stdin=subprocess.PIPE
)
try:
# 3. Stream native audio_int16_bytes directly to prevent truncation
for chunk in self.voice.synthesize(text):
aplay_proc.stdin.write(chunk.audio_int16_bytes)
except BrokenPipeError:
pass
finally:
if aplay_proc.stdin and not aplay_proc.stdin.closed:
aplay_proc.stdin.close()
# Wait for aplay to finish playback to prevent truncation and hardware locks
aplay_proc.wait()
if __name__ == "__main__":
# Clean up lingering playback processes
subprocess.run(["killall", "-9", "aplay"], stderr=subprocess.DEVNULL)
tts = FastOfflineTTS()
print("\n--- Starting Continuous Offline Synthesis Test ---")
tts.speak("Hello, system is initialized and ready.")
tts.speak("Testing second sentence, perfectly smooth and instantaneous.")
tts.speak("Third sentence works reliably.")
MODEL_PATH = os.path.expanduser("~/tts_en_offline/en_US-lessac-low.onnx")
CONFIG_PATH = os.path.expanduser("~/tts_en_offline/en_US-lessac-low.onnx.json")
PCM_DEVICE = "plughw:CARD=Device,DEV=0"
MODEL_PATH Details:
os.path.expanduser("~/..."): Automatically expands ~ to the user home directory (/home/arduino/...), keeping the script portable across systems.en_US-lessac-low.onnx: The neural acoustic model file containing network weights (~16MB).CONFIG_PATH Details:
en_US-lessac-low.onnx.json: Required model metadata mapping phonemes (parsed by espeak-ng) to network tensor IDs, alongside base sample rate details (16000Hz).PCM_DEVICE Details:
plughw:CARD=Device,DEV=0: Explicit ALSA device binding string.CARD=Device protects against hardware enumeration index drift (card 0 vs card 1) after rebooting or replugging USB hardware.plughw plugin layer provides transparent format and channel conversions if slight mismatches occur.self.voice = PiperVoice.load(model_path, config_path=config_path)
self.sample_rate = str(self.voice.config.sample_rate)
piper executable per sentence forces the kernel to reload the 16MB file and rebuild the ONNX Runtime session every time, introducing a 5-6 second delay on Cortex-A53 cores.__init__ holds weights and computation graphs in RAM. Subsequent calls to speak() access pre-warmed models, dropping latency to sub-second levels.self.voice.config.sample_rate matches hardware output timing precisely to model specifications (16000Hz), preventing pitch shifts or speed distortions.aplay_proc = subprocess.Popen(
[
"aplay",
"-D", self.pcm_device,
"-r", self.sample_rate,
"-f", "S16_LE",
"-t", "raw",
"-c", "1",
"-q"
],
stdin=subprocess.PIPE
)
aplay: Standard Linux ALSA audio playback utility with minimal resource consumption.-D: Specifies the target physical USB sound card.-r 16000: Sets the sampling frequency.-f S16_LE: Specifies signed 16-bit little-endian integer format.-t raw: Informs aplay that input is raw PCM without container headers (such as RIFF/WAV).-c 1: Single-channel mono audio.-q: Quiet mode to suppress terminal logging.audio_int16_bytestry:
for chunk in self.voice.synthesize(text):
aplay_proc.stdin.write(chunk.audio_int16_bytes)
except BrokenPipeError:
pass
self.voice.synthesize(text) returns a generator yielding <class 'piper.voice.AudioChunk'> objects.chunk.audio_int16_bytes accesses pre-formatted Python bytes containing raw linear PCM data. Writing this directly avoids type casting issues (TypeError) and eliminates arbitrary chunk-size slicing bugs that cut off speech prematurely.finally:
if aplay_proc.stdin and not aplay_proc.stdin.closed:
aplay_proc.stdin.close()
aplay_proc.wait()
aplay_proc.wait() ensures child processes finish acoustic transmission and release file handles, preventing audio open error: Device or resource busy errors during subsequent invocations.python3 fast_tts_offline.py
aplay: main:850: audio open error: Device or resource busy.
aplay or mpg123 process did not release its hardware handle.killall -9 aplay mpg123 2>/dev/null
cat /proc/asound/cards and verify that the bracketed ID matches CARD=Device. If your output indicates CARD=Audio or CARD=Headset, adjust PCM_DEVICE in your script accordingly.speaker-test -D plughw:CARD=Device,DEV=0 -c 1 -t wav
No such file or directory: 'mpg123'.
mpg123 decoder is missing from the system.sudo apt update && sudo apt install -y mpg123.asyncio.exceptions.TimeoutError or connection resets.
ping -c 3 8.8.8.8. If the device cannot access the internet, switch to the offline synthesis method.zh-CN-XiaoxiaoNeural or zh-CN-YunxiNeural for Chinese, and en-US-AriaNeural for English.piper tool via subprocess for each utterance, forcing the Cortex-A53 to re-parse the 16MB ONNX graph every time.FastOfflineTTS class provided in this guide to keep the model loaded in RAM via PiperVoice.load(), invoking only speak() for subsequent runs.chunk < 2048).chunk.audio_int16_bytes and keeping aplay_proc.wait() in the finally block.FileNotFoundError: No such file or directory: ...lessac-low.onnx.
.onnx and .onnx.json files reside in ~/tts_en_offline/ with matching filenames.