Writing a TTS Plugin¶
In a nutshell
This page is the tutorial for building your own TTS plugin: the TTS base class, the
StreamingTTS variant, the entry point that makes a plugin installable, and how to test
it. Looking for a plugin to use instead of writing one? Go to TTS Plugins.
Writing your own TTS plugin¶
TTS¶
All OVOS TTS plugins need to define a class based on the TTS base class from ovos_plugin_manager.
The base class marks two members abstract: get_tts() and available_languages. A plugin must
implement both, so the minimal example below includes available_languages from the start.
from typing import Set
from ovos_utils import classproperty
from ovos_plugin_manager.templates.tts import TTS
class MyTTSPlugin(TTS):
def get_tts(self, sentence: str, wav_file: str, lang: str = None,
voice: str = None):
# Synthesize `sentence` and write the audio to `wav_file`
[...]
# return the output path and optional per-phoneme visemes (or None)
return wav_file, phonemes
@classproperty
def available_languages(cls) -> Set[str]:
# Languages this plugin can synthesize, as a set of language codes
return {"en-us"}
The base class declares available_languages as a classproperty (from ovos_utils), so it
can be read straight off the class, before anything is instantiated. That is how OVOS builds a
language-to-plugin map for a whole config without constructing every plugin first. It tells OVOS
which languages the plugin supports in its current state (for example, only the languages whose
voice files are already installed).
OVOS uses it to pick a TTS plugin for the configured language and to filter plugin choices in a
UI. Do not skip it. The base implementation has a docstring and no return, so it evaluates to
None — not to an empty set. Any caller doing lang in tts.available_languages then raises
TypeError: argument of type 'NoneType' is not iterable, rather than quietly treating the
plugin as supporting no language.
Entry point¶
To make the class detectable as a TTS plugin, the package needs to provide an entry point under the opm.tts namespace. To expose your sample configurations (the MyTTSConfig dict below) for UI discovery, register them under opm.tts.config:
[project.entry-points."opm.tts"]
example_tts = "my_tts:MyTTSPlugin"
[project.entry-points."opm.tts.config"]
"example_tts.config" = "my_tts:MyTTSConfig"
Backward Compatibility:
ovos-plugin-managerstill supports legacymycroft.plugin.ttsentry points, but new plugins should use theopm.*namespace.
Standalone Usage¶
You can use TTS plugins independently of the full OVOS stack:
from ovos_plugin_manager.tts import find_tts_plugins
# Find and load the plugin
plugins = find_tts_plugins()
tts_class = plugins["ovos-tts-plugin-mimic"]
# Initialize (config only — lang is passed inside the config dict)
tts = tts_class(config={"lang": "en-US"})
# Generate audio
wav_file = "hello.wav"
tts.get_tts("Hello world", wav_file)
print(f"Audio saved to {wav_file}")
TTSValidator¶
TTSValidator is the class OVOS uses to check that a TTS engine is installed and usable before
it starts speaking. TTS.__init__ creates a default TTSValidator(self) when a plugin does not
pass one in. OVOSTTSFactory.create() calls tts.validator.validate() right after building the
plugin instance, which runs, in order:
validate_dependencies()validate_instance()validate_filename()validate_lang()validate_connection()
In the base class every one of those methods is a no-op. A new plugin does not need to write a
TTSValidator at all. The default passes automatically. Write one only if the plugin needs a
real startup check, for example confirming a binary is on PATH or that a cloud endpoint
answers, and raise inside the relevant validate_* method to fail fast with a clear error
instead of failing later on the first get_tts() call.
from ovos_plugin_manager.templates.tts import TTS, TTSValidator
class MyTTSValidator(TTSValidator):
def validate_dependencies(self):
# Raise if a required binary or library is missing
pass
def validate_connection(self):
# Raise if the backend (local process or remote server) is unreachable
pass
class MyTTSPlugin(TTS):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs, validator=MyTTSValidator(self))
See OVOS Plugin Manager: Writing a Plugin for how
registration and the opm.tts entry point fit together at load time: find_tts_plugins()
scans the opm.tts group and load_tts_plugin(name) returns one class from it.
get_tts_class() above is unrelated to that lookup. It is a method every TTSValidator
implements, and it exists so the validator can name the class it validates.
Config plumbing¶
The "tts": {"module": "...", "ovos-tts-plugin-x": {...}} block in mycroft.conf reaches the
plugin instance through OVOSTTSFactory.create():
get_tts_config(config)callsget_plugin_config(config, "tts", module).get_plugin_configreadsconfig["tts"]["ovos-tts-plugin-x"](the module-specific block) and fills in any top-levelttskeys, such aslang, that the module block does not already set.OVOSTTSFactory.create()passes the merged dict to the plugin class asclazz(config=tts_config).TTS.__init__stores it asself.config.
So a setting only reaches the plugin if it lives under tts.<module-name> (or as a shared
top-level key under tts), and the plugin reads it back with self.config.get("my_setting").
See OVOS Plugin Manager: Configuration Priority for
the full precedence rules.
Plugin Template¶
Note
SSML: experimental, engine-dependent. See SSML. Most plugins declare no
ssml_tags and OVOS strips all SSML before synthesis.
from ovos_utils import classproperty
from ovos_plugin_manager.templates.tts import TTS
class MyTTSPlugin(TTS):
def __init__(self, *args, **kwargs):
# Output format, and the SSML tags this engine GENUINELY handles.
# Most engines support none — leave ssml_tags empty/omitted (the
# default) and OVOS strips all SSML before get_tts() runs. Only list
# a tag here if your engine actually understands it; listed tags are
# passed through to get_tts() (optionally rewritten via modify_tag()).
# Full SSML tag set an engine COULD support, for reference:
# ["speak", "s", "w", "voice", "prosody", "say-as", "break", "sub", "phoneme"]
super().__init__(*args, **kwargs, audio_ext="wav")
# Read plugin-specific settings from config
self.voice = self.config.get("voice", "default")
def get_tts(self, sentence, wav_file, lang=None, voice=None):
"""Generate audio data and save to wav_file."""
# Implement your synthesis logic here
# self.my_engine.synthesize(sentence, output_path=wav_file)
# Return path to file and optional visemes for lip-sync
return wav_file, None
@classproperty
def available_languages(cls):
"""Return languages supported by this TTS implementation."""
return {"en-us", "es-es", "pt-pt"}
# Sample valid configurations for plugin discovery
MyTTSConfig = {
lang: [{"lang": lang,
"display_name": f"MyTTS ({lang})",
"priority": 50,
"offline": True}]
for lang in ["en-us", "es-es", "pt-pt"]
}
Reducing time to first audio¶
Two different mechanisms both get audio to the user faster. They are often both called "streaming", so the manual names them apart:
flowchart TD
subgraph Chunking["Sentence chunking (all plugins)"]
A1[Long reply] --> A2["preprocess_sentence<br/>splits into sentences"]
A2 --> A3["get_tts() per sentence"]
A3 --> A4["First sentence plays<br/>while rest synthesizes"]
end
subgraph Streaming["Real streaming - pre-alpha"]
B1[sentence] --> B2["stream_tts() yields<br/>audio chunks"]
B2 --> B3{"tts.enable_streaming?"}
B3 -->|true| B4["ovos-audio plays<br/>chunks as they arrive"]
B3 -->|false| B5["Write full wav_file,<br/>then play: queued path"]
end
Diagram: The chunking flow starts at a long reply and ends with the first sentence playing while the rest synthesizes, and the streaming flow starts at a sentence and branches on tts.enable_streaming between chunk playback in ovos-audio and the queued full-wav path.
Sentence chunking ("fake streaming"). Long replies are split into sentences before
synthesis, so the first sentence plays while the rest still synthesizes. This works with
EVERY TTS plugin because the chunking happens before the engine runs. It is opt-in today:
set "sentence_tokenize": true in the plugin's config block (TTS.preprocess_sentence
splits with quebra_frases, with a newline-split fallback). This is the mechanism to
reach for in practice.
Real streaming. The engine itself emits audio chunks while it synthesizes, through the
StreamingTTS base class below.
Maturity: real streaming is pre-alpha
StreamingTTS is a proof-of-concept. Almost no TTS plugin implements it, and playback
of streamed chunks is gated behind tts.enable_streaming. Use sentence chunking unless
you are experimenting. See the Maturity Scale.
Individual plugins may also expose their own latency settings (smaller models, lower quality modes, caching). Check the plugin's own config table on the TTS Plugins Reference page: anything that shortens synthesis shortens time to first audio.
Streaming support¶
Most engines synthesize the whole sentence before returning, which is what get_tts() above
does. A small number of backends can emit audio incrementally, and the template has a second
base class for that case: StreamingTTS (also in ovos_plugin_manager.templates.tts).
from typing import AsyncIterable
from ovos_plugin_manager.templates.tts import StreamingTTS
class MyStreamingTTS(StreamingTTS):
async def stream_tts(self, sentence: str, **kwargs) -> AsyncIterable[bytes]:
"""yield chunks of TTS audio as they become available"""
async for chunk in self._backend_stream(sentence):
yield chunk
@classproperty
def available_languages(cls):
return {"en-us"}
StreamingTTS subclasses TTS, so it still needs available_languages, and it still answers
get_tts(sentence, wav_file) for callers that only want a finished file. The base class wraps
stream_tts() in an event loop and writes every chunk to wav_file before returning. The one
method a plugin must implement is stream_tts(), an async generator that yields raw audio
bytes as they come off the backend.
Two things only apply to streaming plugins:
- No phonemes.
StreamingTTSdoes not support the(wav_file, phonemes)return shape fromget_tts()for lip-sync; chunks are raw audio only. - Streaming playback is opt-in at the deployment level, not the plugin level. Even when a
plugin implements
stream_tts(),ovos-audioonly plays chunks as they arrive when the deployment config sets"tts": {"enable_streaming": true}. Without that flag_execute()falls back to the normal queued playback path (write the file, then play it), so a streaming plugin still works correctly on a deployment that has not opted in.
Do not implement StreamingTTS unless the backend genuinely streams. There is no benefit to
wrapping a synchronous, whole-file engine in an async generator that yields one chunk.
Package and publish¶
- Pin the dependency version. Put a floor and a ceiling on
ovos-plugin-managerinpyproject.toml, for exampleovos-plugin-manager>=2.3.0a1,<3.0.0. A floor alone lets a future breaking release slip in unnoticed. A ceiling alone lets an old install miss a needed feature. - Install for local development. Run
pip install -e .from the plugin's own repository so changes to the source take effect without reinstalling. See OVOS Plugin Manager: Install and verify for the command that confirms OVOS can see the new plugin. - Publish to PyPI. OVOS deployments and the Plugin Arena's benchmark sweep both install plugins from PyPI, not from a git checkout, so a plugin needs a PyPI release before either can use it. See Plugin Arena: Getting Your Plugin Ranked for what a published plugin needs to be picked up by the sweep.
Test your plugin locally¶
Instantiate the class directly and call get_tts() on it, the same way the Standalone
Usage example does, then check the file it wrote:
from my_tts_package import MyTTSPlugin
tts = MyTTSPlugin(config={"lang": "en-US"})
wav_file, phonemes = tts.get_tts("Hello world", "hello.wav")
assert wav_file == "hello.wav"
Turn that into a pytest test that asserts a real audio file came out:
import os
from my_tts_package import MyTTSPlugin
def test_get_tts_writes_wav_file(tmp_path):
tts = MyTTSPlugin(config={"lang": "en-US"})
out_file = str(tmp_path / "hello.wav")
wav_file, phonemes = tts.get_tts("Hello world", out_file)
assert os.path.isfile(wav_file)
assert os.path.getsize(wav_file) > 0
To exercise the plugin inside a full OVOS install, pip install -e . it into the same virtual
environment or container ovos-core/ovos-audio run in, then set "tts": {"module":
"<your-plugin-entry-point-name>"} in mycroft.conf and restart OVOS, as described in Change
your voice above.
Read next: G2P Plugins Related: TTS Plugins · TTS Plugins Reference · Plugin Manager