Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions .github/workflows/picotts.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# picotts from a bare clone: the unix port renders speech with the real voice
# files, and an ESP32-S3 firmware compiles it under the esp32 port's
# -Werror=all, which the unix build doesn't use.
name: picotts

on:
push:
branches: [main]
paths:
- 'modules/picotts/**'
- 'build_mp.py'
- 'modules.lock'
- '.github/workflows/picotts.yml'
pull_request:
paths:
- 'modules/picotts/**'
- 'build_mp.py'
- 'modules.lock'
- '.github/workflows/picotts.yml'
workflow_dispatch:

permissions:
contents: read

jobs:
unix:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v7

- name: Install the unix port's build dependencies
run: |
sudo apt-get update
sudo apt-get install -y libffi-dev pkg-config

- name: Build the unix port with picotts
run: python3 build_mp.py --port unix --modules picotts

- name: Fetch the voice files named in voices/en-US.json
run: |
mkdir -p "$RUNNER_TEMP/voice"
python3 - "$RUNNER_TEMP/voice" <<'PY'
import json, sys, urllib.request
for dest, url in json.load(open("modules/picotts/voices/en-US.json"))["urls"]:
urllib.request.urlretrieve(url, sys.argv[1] + "/" + dest.split("/")[-1])
PY
ls -l "$RUNNER_TEMP/voice"

- name: The engine speaks, and the API around it
run: ./builds/unix/standard/micropython -X heapsize=12M modules/picotts/tests/test_picotts.py "$RUNNER_TEMP/voice"

esp32s3:
runs-on: ubuntu-latest
timeout-minutes: 120
env:
CCACHE_DIR: /home/runner/.cache/ccache
CCACHE_MAXSIZE: 400M
CCACHE_COMPILERCHECK: content
CCACHE_BASEDIR: ${{ github.workspace }}
CMAKE_C_COMPILER_LAUNCHER: ccache
CMAKE_CXX_COMPILER_LAUNCHER: ccache
CMAKE_ASM_COMPILER_LAUNCHER: ccache
IDF_CCACHE_ENABLE: '1'
steps:
- uses: actions/checkout@v7

- name: Packages the build needs
run: |
sudo apt-get update
sudo apt-get install -y ccache gettext cmake ninja-build pkg-config libffi-dev

- name: The runner's Python, which the cached venvs are tied to
id: py
run: echo "version=$(python3 -c 'import sys; print("%d.%d" % sys.version_info[:2])')" >> "$GITHUB_OUTPUT"

# release-firmware.yml's cache, the same paths and key so it's shared.
- name: Cache ESP-IDF
uses: actions/cache@v6
with:
path: |
deps/esp-idf
deps/circuitpython
deps/circuitpython-venv
~/.espressif
key: deps-idf-py${{ steps.py.outputs.version }}-${{ hashFiles('deps.lock', 'patches/circuitpython/**') }}

- name: Cache compiler output
uses: actions/cache@v6
with:
path: /home/runner/.cache/ccache
key: ccache-picotts-s3-${{ hashFiles('UPSTREAM', 'modules.lock') }}-${{ github.run_id }}
restore-keys: |
ccache-picotts-s3-${{ hashFiles('UPSTREAM', 'modules.lock') }}-
ccache-picotts-s3-

- name: Build an ESP32-S3 firmware with picotts
run: python3 build_mp.py --port esp32 --board ESP32_GENERIC_S3 --variant SPIRAM_OCT --modules picotts

- name: picotts is linked in
run: |
map=builds/esp32/ESP32_GENERIC_S3/SPIRAM_OCT/micropython.map
grep -q 'pico_initialize' "$map"
grep -q 'picotts_user_cmodule' "$map"
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
!/modules/jpegio/
!/modules/mdns/
!/modules/pngio/
!/modules/picotts/
!/modules/tflite/
!/modules/vision/
!/modules/.esp-vision/
Expand Down
1 change: 1 addition & 0 deletions NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Modules that vendor code keep it under their own third_party/ folder and
list it in a NOTICE of their own:

modules/jpegio/NOTICE TJpgDec (ChaN) and stb_image_write's JPEG writer
modules/picotts/NOTICE the SVOX Pico text-to-speech engine (Apache-2.0)

The tflite and vision modules compile code fetched at build time from
esp-vision, at the commit pinned in modules/.esp-vision/ESP_VISION.lock.
Expand Down
8 changes: 8 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ More models for tflite:
ops, which tflite doesn't register; until then `audiofeatures` makes the
same features.

picotts, speech on the board:

- **Speech that starts sooner.** `say()` renders a whole sentence before it
plays, which on an ESP32-S3 is 1 to 4 s of silence first. The engine runs at
twice real time there, so a short lead would do once its longest step fits.
- **picotts on the ESP32-P4 boards.** It is built and run on an ESP32-S3
only so far.

Every module, the same kit:

- **Type stubs and `__revision__` everywhere.** castif, h264enc, jpegio, mdns,
Expand Down
5 changes: 3 additions & 2 deletions docs/guides/choose-modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ A name is one of three things:
`displayif`, `pygraphics`, `pydevices`.
- **A path**, to a module of your own: a directory with a `manifest.py`, or a
C module with its `micropython.mk` or `micropython.cmake`.
- **`all`**: every module except the opt-in ones. `tflite` and `vision` are
opt-in (each has an `OPT_IN` file saying why), so name them to get them:
- **`all`**: every module except the opt-in ones. `picotts`, `tflite` and
`vision` are opt-in (each has an `OPT_IN` file saying why), so name them to
get them: `--modules all,picotts`, and for the other two
[Use esp-vision's modules](use-esp-vision-modules.md).

A module never brings another, and nothing checks dependencies. If you name
Expand Down
4 changes: 3 additions & 1 deletion docs/reference/modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ tracked here or linked from a sibling checkout or cloned at the commit in
| [`h264enc`](https://github.com/PyDevices/micropython-pydevices/tree/main/modules/h264enc) | RGB565 frames to H.264 on the ESP32-P4's hardware encoder. |
| [`jpegio`](https://github.com/PyDevices/micropython-pydevices/tree/main/modules/jpegio) | JPEG decode and encode, with CircuitPython's `jpegio` API. |
| [`mdns`](https://github.com/PyDevices/micropython-pydevices/tree/main/modules/mdns) | Find and advertise services on the local network, with CircuitPython's `mdns` API (esp32, rp2, unix, windows). |
| [`picotts`](https://github.com/PyDevices/micropython-pydevices/tree/main/modules/picotts) | Text to speech on the SVOX Pico engine, with the API of Adafruit's `adafruit_picotts`. Opt-in, and it needs PSRAM. |
| [`pngio`](https://github.com/PyDevices/micropython-pydevices/tree/main/modules/pngio) | PNG encode and decode, RGB565 in and out. |
| [`ppa`](https://github.com/PyDevices/micropython-pydevices/tree/main/modules/ppa) | Scale, rotate, convert and fill pixel buffers, on the ESP32-P4's Pixel Processing Accelerator or in portable C elsewhere. |
| [`tsmux`](https://github.com/PyDevices/micropython-pydevices/tree/main/modules/tsmux) | MPEG-TS and HLS segments from H.264. |
Expand All @@ -30,7 +31,7 @@ tracked here or linked from a sibling checkout or cloned at the commit in
| `tflite`, `vision` | [esp-vision's modules](../guides/use-esp-vision-modules.md), opt-in: `all` leaves them out. |

[`modules/all`](https://github.com/PyDevices/micropython-pydevices/tree/main/modules/all) is not a module but the rule behind `all`: every module beside it
except those with an `OPT_IN` file.
except those with an `OPT_IN` file: `picotts`, `tflite` and `vision`.

## Which modules build on CircuitPython

Expand All @@ -56,6 +57,7 @@ it is. "Unix" is CircuitPython's unix port, built and imported in
| ulab | CircuitPython's own | CircuitPython's own | |
| castif | no: it is the ESP32-P4's | ESP32-P4, with no network | Talks to lwIP directly, the lwIP under CircuitPython's own `socketpool`. CircuitPython's P4 port has no network until it drives the boards' ESP32-C6, so castif is built without one and `start()` says so. Name tsmux, h264enc and ppa with it. |
| h264enc | no: it is the ESP32-P4's encoder | ESP32-P4 | Espressif's `esp_h264`, which CircuitPython's port, built with the component manager off, can't fetch itself, is pinned in `deps.lock` and fetched by `build_mp.py`. Name ppa with it. |
| picotts | no | no, on purpose | CircuitPython has Adafruit's `adafruit_picotts`, the same engine and API as a native `.mpy`. |
| tflite, vision | no | no, on purpose | esp-vision's modules, for MicroPython only. |
| lvgl-micropython | no | no | CircuitPython's binding is `lvgl-circuitpython`, passed by path. |
| micropython-lib | no | no | MicroPython's own upstream packages. |
Expand Down
20 changes: 20 additions & 0 deletions modules/picotts/NOTICE
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
picotts
Copyright (c) 2026 PyDevices, MIT License (see the repository's LICENSE).

This module includes code from other projects.

third_party/svox/
The SVOX Pico text-to-speech engine, Copyright (C) 2008-2009 SVOX AG.
Apache License 2.0: third_party/svox/LICENSE, with the engine's own notice
in third_party/svox/NOTICE. ORIGIN.txt names the upstream commits and every
change made to the files.

src/modpicotts.c and py/picotts.py
Ports of Adafruit_CircuitPython_PicoTTS's src/picotts_native.c and
adafruit_picotts/__init__.py, Copyright (c) 2026 Mikey Sklar and Adafruit
Industries, MIT License. The upstream copyright lines are kept in each
file's header, and the changes are listed in picotts.py's docstring.

The en-US voice files (voices/en-US.json installs them) are SVOX AG's,
Apache License 2.0. They are not stored in this repository; they are
downloaded from Adafruit_CircuitPython_PicoTTS at the commit above.
3 changes: 3 additions & 0 deletions modules/picotts/OPT_IN
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Opt-in: name picotts to build it; --modules all leaves it out.
It adds the SVOX Pico engine to the image, and it only runs on a board with
PSRAM (about 2.5 MB for the engine and its voice).
86 changes: 86 additions & 0 deletions modules/picotts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# picotts — text to speech on the board

`import picotts` makes a board with PSRAM speak, with no network and no PC.
It is the SVOX Pico engine, an en-US voice at 16 kHz that handles numbers,
abbreviations and dates, behind the API of Adafruit's
[`adafruit_picotts`](https://github.com/adafruit/Adafruit_CircuitPython_PicoTTS),
so [Adafruit's text-to-speech guide](https://learn.adafruit.com/circuitpython-text-to-speech)
applies with two changes: the import name, and the audio output you hand it.

```python
import board_peripherals
import picotts
from audiodev import AudioFormat

audio = board_peripherals.audio_out(AudioFormat(16000, 1, 16))
tts = picotts.TTS(audio)
tts.say("Doctor Smith read 1,234 pages on the 1st of May.")
```

[`examples/picotts_simpletest.py`](examples/picotts_simpletest.py) says three
sentences and prints how long each took.

## What you need

- **A board with PSRAM.** The engine's work area is 1.1 MB and the voice
1.43 MB, about 2.5 MB together. On a board without PSRAM, `TTS()` raises
`MemoryError` saying so before it allocates anything. An ESP32-S3 or P4
with 8 MB or more is comfortable.
- **Firmware built with picotts.** It is opt-in, so `all` leaves it out:

```bash
./build_mp.py --port esp32 --board ESP32_GENERIC_S3 --variant SPIRAM_OCT --modules all,picotts
```

It adds about 170 KB of engine code to the image. MicroPython only: on
CircuitPython, use Adafruit's library, which is the same engine as a
native `.mpy`.
- **The voice files in `/lib/picotts/`.** Two files, `en_US_ta.bin`
(650 KB) and `en_US_lh0_sg.bin` (777 KB). With the board on Wi-Fi, `mip`
fetches them from Adafruit's repository, at a pinned commit:

```python
import mip
mip.install("github:PyDevices/micropython-pydevices/modules/picotts/voices/en-US.json")
```

Or download the two files from the URLs in
[`voices/en-US.json`](voices/en-US.json) and copy them to `/lib/picotts/`
with `mpremote cp` or `mpftp put`. `TTS(audio, voice_dir="/sd/voice")`
reads them from another folder.
- **An audio output at 16,000 Hz.** Speech comes out at 16 kHz mono, and
`AudioOut` doesn't resample, so open it at that rate or the voice plays at
the wrong pitch. `TTS` also accepts `audiobusio.I2SOut`, or a voice of your
own `audiomixer.Mixer` running at 16 kHz.

## Why the voice is a file

The voice lives on the filesystem, not in its own flash partition and not in
the firmware image. A partition would change the board's partition table,
which moves or shrinks the filesystem, and every board's table differs. In the
image it would add 1.43 MB to an app slot that is often nearly full. As files
it installs like any package, the firmware stays the same for every language,
and nothing about the board changes. The cost is that `TTS()` reads the files
into PSRAM, which takes a moment once, and the engine reads them there.

## API

The same as `adafruit_picotts`: `TTS(audio, *, voice_dir=None)`, then
`say(text, *, wait=True, interrupt=False)`, `update()` after
`say(..., wait=False)`, `stop()`, `deinit()`, the `rate` (words per minute,
default 150), `pitch` (0.5 to 2.0), `volume` (0.0 to 1.0) and `voice`
properties, `voices`, `speaking` and `sample_rate`. `voice_dir` is the one
addition.

Underneath is the C module `_picotts`: `Engine(ta, sg, memory)` renders text
to samples with `start(text)`, `render(buffer)`, `speaking`, `stop()` and
`deinit()`, for when you want the samples themselves (to save a WAV, or feed
your own player). [`tests/test_picotts.py`](tests/test_picotts.py) uses it
that way.

## Licence

The engine is SVOX AG's, under the Apache License 2.0, and so are the voice
files. [NOTICE](NOTICE) lists where every file came from;
[third_party/svox/ORIGIN.txt](third_party/svox/ORIGIN.txt) lists every change
made to the engine on its way here.
49 changes: 49 additions & 0 deletions modules/picotts/examples/picotts_simpletest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# SPDX-FileCopyrightText: 2026 PyDevices
#
# SPDX-License-Identifier: MIT
"""Speak a few sentences through the board's speaker.

Text to speech on the board itself, with no network: the SVOX Pico engine
renders 16 kHz speech, and the board's audio output plays it. The output is
opened at 16 kHz because it doesn't resample. Each sentence prints how long it
took to say.

Needs: a board with PSRAM and a speaker or I2S amplifier, firmware built with
the picotts module, a board config with an audio_out role, and the en-US voice
files in /lib/picotts/ (see the module README).

Run it:
mpftp run picotts_simpletest.py

Tested on: LilyGO T-Embed (ESP32-S3, 8 MB PSRAM), MicroPython 1.30.0-preview.
"""

import time

import board_peripherals
import picotts
from audiodev import AudioFormat

# Change these to match your setup.
VOLUME = 0.6 # 0.0 to 1.0
SENTENCES = (
"Hello from Micro Python.",
"This is the S VOX Pico voice.",
"Doctor Smith read 1,234 pages on the 1st of May.",
)

# Open the speaker at the engine's rate, 16 kHz mono.
audio = board_peripherals.audio_out(AudioFormat(16000, 1, 16))

# Load the engine and the voice (about 2.5 MB of PSRAM).
tts = picotts.TTS(audio)
tts.volume = VOLUME

# Say each sentence and print how long it took.
for text in SENTENCES:
start = time.ticks_ms()
tts.say(text)
print("%s %.2f s" % (text, time.ticks_diff(time.ticks_ms(), start) / 1000))
time.sleep(0.5)

tts.deinit()
5 changes: 5 additions & 0 deletions modules/picotts/manifest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# picotts: text to speech on the SVOX Pico engine, the API of Adafruit's
# adafruit_picotts. The engine is C (_picotts); the TTS class is frozen Python.
# Needs PSRAM on a microcontroller: about 2.5 MB for the engine and voice.
c_module(".")
module("picotts.py", base_path="py")
40 changes: 40 additions & 0 deletions modules/picotts/micropython.cmake
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# picotts CMake glue (see micropython.mk for what the settings mean).

set(PICOTTS_DIR ${CMAKE_CURRENT_LIST_DIR})
set(PICOTTS_SVOX ${PICOTTS_DIR}/third_party/svox)

# The esp32 port compiles user C modules with -Werror=all, and per-file flags
# don't reach them there, so the SVOX engine (third-party code, left as it
# came) builds through one small wrapper per file that turns its warnings off
# and includes the original.
set(PICOTTS_WRAP ${CMAKE_CURRENT_BINARY_DIR}/picotts_svox)
file(MAKE_DIRECTORY ${PICOTTS_WRAP})
file(GLOB PICOTTS_SVOX_SRC ${PICOTTS_SVOX}/*.c)
set(PICOTTS_SVOX_WRAPPED)
foreach(src ${PICOTTS_SVOX_SRC})
get_filename_component(name ${src} NAME)
set(wrapped ${PICOTTS_WRAP}/${name})
set(body "/* Generated by modules/picotts/micropython.cmake. */\n")
foreach(w maybe-uninitialized uninitialized unused-variable
unused-but-set-variable unused-function unused-parameter
unused-value unused-label sign-compare implicit-fallthrough
missing-field-initializers old-style-declaration type-limits
misleading-indentation parentheses empty-body
int-in-bool-context array-bounds stringop-overflow address
pointer-sign char-subscripts dangling-else switch format
shift-negative-value bool-operation)
string(APPEND body "#pragma GCC diagnostic ignored \"-W${w}\"\n")
endforeach()
string(APPEND body "#include \"${src}\"\n")
file(CONFIGURE OUTPUT ${wrapped} CONTENT "${body}" @ONLY)
list(APPEND PICOTTS_SVOX_WRAPPED ${wrapped})
endforeach()

add_library(usermod_picotts INTERFACE)
target_sources(usermod_picotts INTERFACE
${PICOTTS_DIR}/src/modpicotts.c
${PICOTTS_SVOX_WRAPPED}
)
target_include_directories(usermod_picotts INTERFACE ${PICOTTS_SVOX})
target_compile_definitions(usermod_picotts INTERFACE PICO_PLATFORM=PICO_CIRCUITPY)
target_link_libraries(usermod INTERFACE usermod_picotts)
Loading
Loading