Writing an STT Plugin¶
In a nutshell
This page is the tutorial for building your own STT plugin: the STT and StreamingSTT base classes, 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 STT Plugins.
Writing your own STT plugin¶
STT¶
The base STT, this handles the audio in "batch mode" taking a complete audio file, and returning the complete transcription.
Each STT plugin class needs to define the execute() method taking two arguments:
-
audio\([AudioData](https://github.com/Uberi/speech_recognition/blob/master/reference/library-reference.rst#audiodataframe_data-bytes-sample_rate-int-sample_width-int---audiodata) object\) - the audio data to be transcribed. -
language\(str\) - optional - the BCP-47 language code. When omitted, the plugin uses its configured or detected language
The bare minimum STT class will look something like
from ovos_plugin_manager.templates.stt import STT
class MySTT(STT):
def execute(self, audio, language=None):
# Handle audio data and return transcribed text
[...]
return text
N-best hypotheses: transcribe()¶
execute() returns a single best string. The richer method is transcribe(audio, lang=None),
which returns a list of (transcript, confidence) tuples, confidence a float from 0.0 to 1.0.
The template implements it for you as [(self.execute(audio, lang), 1.0)], so a plugin that
only knows its single best answer needs to implement nothing extra.
If the wrapped engine can produce several hypotheses with scores, override transcribe().
It is the preferred entry point, and consumers that rescore or disambiguate between
candidates read it. execute() then remains the single-best convenience wrapper.
class MySTT(STT):
def execute(self, audio, language=None):
return self.transcribe(audio, language)[0][0]
def transcribe(self, audio, lang=None):
# return hypotheses best-first
return [("turn on the lights", 0.91),
("turn on the light", 0.62)]
Language detection¶
An STT plugin can be paired with an audio language detector. The audio service calls
bind(detector) to hand the plugin an AudioLanguageDetector. The plugin then exposes:
-
detect_language(audio, valid_langs=None)→(lang, confidence). It delegates to the bound detector, defaultingvalid_langsto the plugin's ownavailable_languages. With no detector bound it raisesNotImplementedError. Language detection is opt-in per deployment. -
transcribe(audio, lang=None)withlang="auto"passed explicitly. The"auto"sentinel runsdetect_language()first and transcribes in whatever it returns. If detection fails, the plugin falls back toself.langand transcribes anyway, so"auto"never turns a detector problem into a failed transcription.
StreamingSTT¶
A more advanced STT class for streaming data to the STT. This will receive chunks of audio data as they become available and they are streamed to an STT engine.
The plugin author needs to implement the create_streaming_thread() method creating a thread for handling data sent through self.queue.
The thread this method creates should be based on the StreamThread class. Its abstract handle_audio_stream(audio, language) method also needs to be implemented. It receives a generator of audio chunks and should set self.text to the transcript. finalize() returns that stored text once the stream ends.
Chunk semantics¶
sequenceDiagram
participant Mic as Mic thread
participant STT as StreamingSTT
participant Thread as StreamThread
Mic->>STT: stream_start(language)
STT->>Thread: create_streaming_thread()
Thread->>Thread: handle_audio_stream(audio, language)
Note over Thread: audio is a generator that yields queued chunks
loop each captured chunk
Mic->>STT: stream_data(chunk)
STT->>Thread: queue.put(chunk)
end
Mic->>STT: stream_stop()
STT->>Thread: queue.put(None)
Thread-->>STT: finalize() returns self.text
Diagram: The sequence starts with the mic thread calling stream_start and ends with the stream thread returning finalize() text. The stream thread calls handle_audio_stream one time, and the loop between those two points feeds audio chunks to the generator it reads.
Audio arrives synchronously per chunk: stream_data() is called once per captured
chunk on the mic thread, so it must return well under the per-chunk time budget
(the same real-time cadence constraint a wake-word plugin's update(chunk) runs
under. See Wake-word Plugins: Key Methods). Do any
slow work (network calls, heavy inference) on the StreamThread this class
manages, not inline in stream_data().
StreamingSTT runs the streaming work on a background thread, fed through a queue:
stream_start(language=None)creates a freshQueue, builds aStreamThreadviacreate_streaming_thread(), and starts it.- Each call to
stream_data(chunk)puts one raw PCMbyteschunk (16 kHz/16-bit/mono,chunk_size-sized; see the microphone interface for the audio-format contract) onto that queue. - The
StreamThread'srun()calls yourhandle_audio_stream(audio, language)withaudioas a generator that yields chunks off the queue until aNonesentinel appears. Your implementation should loop over it (e.g.for chunk in audio:) and feed each chunk to the underlying engine, settingself.textas partial/final results arrive. stream_stop()pushes theNonesentinel, joins the thread, and callsfinalize()on it to retrieve the storedself.textas the final transcript. This is also whatexecute()returns for aStreamingSTTplugin.
A complete minimal streaming plugin:
from queue import Queue
from threading import Thread
from ovos_utils import classproperty
from ovos_plugin_manager.templates.stt import StreamingSTT, StreamThread
class MyStreamThread(StreamThread):
def __init__(self, queue: Queue, language: str, engine):
super().__init__(queue, language)
self.engine = engine
def handle_audio_stream(self, audio, language):
# `audio` is a generator of raw PCM byte chunks; `self.queue.get()`
# returns None once the caller closes the stream.
for chunk in audio:
partial = self.engine.feed(chunk, language)
if partial:
self.text = partial
return self.text
class MyStreamingSTT(StreamingSTT):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.engine = MyStreamingEngine()
def create_streaming_thread(self):
return MyStreamThread(self.queue, self.lang, self.engine)
@classproperty
def available_languages(cls):
return {"en-us"}
Entry point and a minimal working plugin¶
Project layout:
ovos_stt_plugin_mymodel/__init__.py. available_languages is a classproperty on the
base class, so a plugin overrides it the same way, not as a plain instance @property:
from ovos_utils import classproperty
from ovos_plugin_manager.templates.stt import STT
class MySTTPlugin(STT):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# read config settings for your plugin
self.lm = self.config.get("language-model")
self.hmm = self.config.get("acoustic-model")
def execute(self, audio, language=None):
# Implement STT engine logic here
transcript = "You said this"
return transcript
@classproperty
def available_languages(cls):
"""Return languages supported by this STT implementation in this state.
This property should be overridden by the derived class to advertise
what languages that engine supports.
Returns:
set: supported BCP-47 language codes
"""
return {"en-us", "es-es"}
# sample valid configurations per language
# "display_name" and "offline" provide metadata for UI
# "priority" is used to calculate position in selection dropdown
# 0 - top, 100-bottom
# all other keys represent an example valid config for the plugin
MySTTConfig = {
lang: [{"lang": lang,
"display_name": f"MySTT ({lang})",
"priority": 70,
"offline": True}]
for lang in ["en-us", "es-es"]
}
The package needs an entry point under the opm.stt group to be detectable as a plugin,
plus the parallel opm.stt.config group to expose MySTTConfig for UI discovery. The
entry-point name (left of =) is the string users put in their mycroft.conf:
[project]
name = "ovos-stt-plugin-mymodel"
version = "0.1.0"
dependencies = ["ovos-plugin-manager"]
[project.entry-points."opm.stt"]
ovos-stt-plugin-mymodel = "ovos_stt_plugin_mymodel:MySTTPlugin"
[project.entry-points."opm.stt.config"]
ovos-stt-plugin-mymodel.config = "ovos_stt_plugin_mymodel:MySTTConfig"
Backward Compatibility:
ovos-plugin-managerstill supports legacymycroft.plugin.sttentry points, but new plugins should use theopm.*namespace.
Test it without OVOS¶
STT is a plain class with no messagebus connection, so a unit test needs no running
OVOS stack:
from ovos_stt_plugin_mymodel import MySTTPlugin
plug = MySTTPlugin()
assert "en-us" in plug.available_languages
transcript = plug.execute(audio=None, language="en-us")
assert isinstance(transcript, str)
STT plugins can equally be exercised against a real audio file, without a running OVOS
instance, by importing the plugin class directly. For example, with
ovos-stt-plugin-vosk:
from ovos_stt_plugin_vosk import VoskKaldiSTT
from ovos_plugin_manager.utils.audio import AudioFile
plug = VoskKaldiSTT()
# verify lang is supported
lang = "en-us"
assert lang in plug.available_languages
# read the whole file into an AudioData object
with AudioFile("test.wav") as source:
audio = source.read()
# transcribe
transcript = plug.execute(audio, lang)
Verify discovery¶
After pip install -e .:
from ovos_plugin_manager.stt import find_stt_plugins
print(find_stt_plugins())
# {'ovos-stt-plugin-mymodel': <class '...MySTTPlugin'>}
load_stt_plugin(name) returns the same uninstantiated class for one plugin name. You
construct it yourself with your config dict.
Checklist before you publish¶
- The class subclasses
STT(orStreamingSTT) and implementsexecute(orcreate_streaming_threadandStreamThread.handle_audio_stream) plusavailable_languagesas aclassproperty. - The entry-point group in
pyproject.tomlisopm.stt, with a matchingopm.stt.configentry once the plugin has settings worth advertising in UIs. __init__acceptsconfig=Noneand works with an empty config.language=Nonefalls back to the plugin's configured/detected language.- Unit tests exercise
execute/transcribedirectly, with no OVOS services running. find_stt_plugins()discovers the installed plugin under the expected name.
Read next: STT Plugins Related: STT Plugins Reference · Wake-word Plugin Development · Plugin Manager