Skip to main content

Common Reading

An OVOS pipeline plugin that reads things aloud - fairy tales, articles, news, documents, reports, and whatever else a provider skill wants to offer - by orchestrating "read me something" across those provider skills, the same way OCP (ovos-common-play) orchestrates "play X" across media skills.

"If you want your children to be intelligent, read them fairy tales. If you want them to be more intelligent, read them more fairy tales." — Albert Einstein

Tests PyPI version

What this is: a pipeline plugin for narrating text-based content via TTS - providers deliver plain text, and this plugin reads it aloud, sentence by sentence, with bookmarking and "continue" support.

What this is not: an audiobook or audio-file player. If you're looking for pre-recorded audiobooks, radio dramas, or narrated readings as audio files, that's already well covered by existing OCP media skills (e.g. ovos-skill-librivox, or JarbasSkills' skill-golden-audiobooks / skill-hppodcraft / skill-epic-horror-theatre). This plugin solves a different problem: letting several text content providers coexist and be searched together, without any of them registering competing voice intents.

Install

pip install ovos-common-reading-pipeline-plugin

Add it to your pipeline in mycroft.conf in two places: the -high stage right after your stop matcher, and the -low stage after padatious/adapt, before common query and the fallbacks (see How a request is recognised). The -high stage goes (stop_high/ovos-stop-pipeline-plugin-high) - roughly where OCP sits, not before stop. "Stop" should always be the most reliable, highest-priority command regardless of what skill is active; putting anything ahead of it undermines that guarantee for every skill, not just this one. An earlier version of this README recommended putting this plugin first - that was wrong, and has been reverted; see the note at the end of this section for why.

Don't copy-paste a whole pipeline block from here or anywhere else - your existing list already has entries specific to your install (the exact names/order vary: stop_high vs ovos-stop-pipeline-plugin-high, whether persona/OCP/common-query plugins are present, etc). Find your existing "pipeline" array and insert one line, right after your stop matcher:

   "pipeline": [
     "stop_high",
+    "ovos-common-reading-pipeline-plugin-high",
     "converse",
     "ocp_high",
     ...
     "adapt_medium",
+    "ovos-common-reading-pipeline-plugin-low",
     "common_qa",
     ...
   ]

(a real, complete example from a live install - yours will look similar but isn't guaranteed to match exactly, which is exactly why editing your existing list beats copying a block):

"pipeline": [
  "ovos-stop-pipeline-plugin-high",
  "ovos-common-reading-pipeline-plugin-high",
  "ovos-converse-pipeline-plugin",
  "ovos-ocp-pipeline-plugin-high",
  "ovos-persona-pipeline-plugin-high",
  "ovos-padatious-pipeline-plugin-high",
  "ovos-fallback-pipeline-plugin-high",
  "ovos-adapt-pipeline-plugin-high",
  "ovos-stop-pipeline-plugin-medium",
  "ovos-adapt-pipeline-plugin-medium",
  "ovos-common-reading-pipeline-plugin-low",
  "ovos-common-query-pipeline-plugin",
  "ovos-fallback-pipeline-plugin-medium",
  "ovos-persona-pipeline-plugin-low",
  "ovos-fallback-pipeline-plugin-low"
]

This plugin registers its own dedicated pause/continue intents and correctly declines (None) for anything it doesn't recognize or isn't currently relevant to, so this position - right after stop, ahead of everything else - is enough for pause/continue to reliably reach it without needing to jump the stop queue.

The plain id ovos-common-reading-pipeline-plugin (what this README recommended before 0.3.0) still works: it runs both stages at that one spot. Adding the -low stage further down is what lets a skill whose own intent matches a bare title get it first. ovos-core hands the plugin whatever is under intents["ovos-common-reading-pipeline-plugin"] in mycroft.conf, and reads two keys from it itself (match_timeout, default 10 s, and match_workers, default 4). The plugin reads the keys below from the same place, or from its settings file (~/.config/mycroft/skills/ovos-common-reading-pipeline-plugin.andlo/settings.json) when mycroft.conf doesn't set them:

"intents": {
  "ovos-common-reading-pipeline-plugin": {
    "narration": "ssml",
    "chars_per_second": 14,
    "wait_margin": 3
  }
}
key default what it does
narration off "ssml" sends each sentence with an SSML version beside it (see Narration)
chars_per_second 14 speaking rate the wait after each line is sized with (see below)
wait_margin 3 seconds added to that wait, for synthesis

How a request is recognised

Like OCP: the sentence has to open with a reading verb ("tell me", "read me", "can you read", "fortæl mig", "lies mir", "raconte-moi"...), and what follows has to name something a provider said it can read. The pipeline itself only knows the words that take a sentence apart (locale/<lang>/reading.json: verbs, "about"/"from" connectors, articles, and the story words); what can be read comes from the providers (the vocabulary message):

  • -high stage (right after stop): the sentence names a kind of text ("a story", "my horoscope", "an article about...") or a collection ("a story from grimm", "a grimm story"). "Tell me the weather", "tell me the time", "read my messages" name neither, so they go to the skills they belong to (#50).
  • -low stage (after padatious/adapt): a bare title ("tell me the little mermaid", "læs den lille havfrue"), only when a provider announced that title.

The sentence is then split into what goes on to the providers: the kind of text (as the canonical name the provider announced, e.g. "horoscope" for "horoskop"), the collection, and whatever is left as the title. "Tell me my leo horoscope" is content_type: "horoscope", title "leo" (#33). A provider with a new kind of text ("recipe", "poem") is reachable as soon as it announces the word, with no pipeline release (#36). Only "continue" and "pause" are still padacioso intents.

How a request is handled

  • match_high()/match_low() only recognize the utterance (a few milliseconds) and claim it or not. It never searches, speaks or waits: ovos-core 3.7 gives each plugin's match() 10 s on a small worker pool, and an utterance whose match() runs longer falls through to the later pipeline stages.
  • ovos-core then dispatches <skill_id>:<intent> (ovos-common-reading-pipeline-plugin.andlo:read_content, :read_by_collection, :read_by_type, :read_any_story, :continue, :pause) to the handler the plugin registered for it. The handler searches the providers, asks "is it that one?" when the best match is unsure, announces the story, starts reading it and returns. That return is what ends the turn (mycroft.skill.handler.complete, then ovos.utterance.handled), about two seconds after the request (the search window), so "stop", "pause" and anything else reach the assistant while the story plays.
  • The story is read on a background thread, one sentence at a time. Each sentence is forwarded from the request that started the story, so it carries that request's session and route, and each waits for that session's recognizer_loop:audio_output_end before the next one goes out. A client that reports its playback paces the story. One that doesn't is waited on for as long as the sentence takes to say: its length at chars_per_second plus wait_margin, rounded up to whole seconds and never more than 15 s (see #41). The announcement and the other lines the plugin waits on are sized the same way.

If you write a client that plays speech itself (a HiveMind satellite, a phone app), send recognizer_loop:audio_output_start when a line starts playing and recognizer_loop:audio_output_end when it ends, with the session of the speak it came from in the context. That is what lets the story go on the moment a sentence ends; without it, the plugin can only guess how long each sentence takes.

Narration

With "narration": "ssml", every sentence of a story goes out with an SSML version of itself in data["utterance_ssml"], beside the plain data["utterance"], which is exactly what it would be without it. The SSML is never put in utterance: a client that doesn't render SSML would show or say the tags, while one that doesn't know utterance_ssml simply ignores it. Each sentence is still its own speak and its own <speak> document, so bookmarks and pacing are unchanged. It is off by default.

The narrator only acts on what the text marks, and leaves the rest to the voice:

  • a 750 ms pause before the first sentence read (after the announcement or "continue") and 500 ms before the first sentence of every other paragraph (a provider that sends the whole story as one paragraph gets none);
  • a dash right after the end of a sentence, the way Cosquin's text marks the next speaker (maltraité?—Si tu te plains), becomes a 300 ms pause;
  • any other dash between words (I think—I know I think—it might be little Kay) becomes a 200 ms pause; a dash between two numbers, or at either end of the sentence, is left alone;
  • … is written ..., which Phoonnx voices pause on and the single character they read straight through, and an ellipsis in the middle of a sentence gets a 250 ms pause.
<speak><break time="500ms"/>The eldest was proud <break time="200ms"/> far too proud.</speak>

Quoted dialogue, ! and ? are left alone.

The news is left to news skills

"Read the news", "read me the latest news about France", "read me today's news" and their equivalents in the other languages are not claimed. This plugin sits ahead of every news skill in the pipeline, so whatever it claims never reaches one, and no provider in this family serves the news. No provider announces the news as a kind of text, and a request whose title or kind of text holds a word from locale/<lang>/news.voc is declined anyway. French keeps "une nouvelle", which in a reading request is as often a short story as a news item; French asks for the news in the plural ("les nouvelles"), which nothing here takes.

Several users at once (HiveMind hubs)

On a HiveMind hub every connected client talks to the same ovos-core, and so to the same instance of this plugin. Everything a story needs is kept per session (the session_id in the message context): whether it is being read, what "continue" resumes and where, and the stop flag. One user's "stop", "pause" or "continue" only ever touches their own story. A stop that names no session at all (the plugin shutting down) stops every story.

Bookmarks live in the plugin's settings file, per session:

{
  "sessions": {
    "<session_id>": {
      "last_content": {"skill_id": "...", "content_id": "...", "title": "..."},
      "last_lang": "fr-FR",
      "progress": {"<skill_id>::<content_id>": 12},
      "progress_splitter": {"<skill_id>::<content_id>": 2},
      "touched": 1790000000.0
    }
  }
}

The 50 most recently used sessions are kept, plus default and any session reading right now. Settings from earlier versions (a single last_content/progress at the top level) are moved to the default session on start, so a paused story on a single device still continues.

A separate, unresolved issue: "stop" itself may not reach this plugin at all

This is not a pipeline-ordering problem, and moving this plugin around won't fix it. stop_high doesn't broadcast to every active skill - it internally picks one specific skill to call .stop() on. On at least one real install (confirmed live via ovos-tui-client's activity log), it picked a separate system skill (ovos-skill-stop, skill_id stop.openvoiceos - which by its own description "doesn't do anything directly" and exists for hardware/enclosure signaling) instead of whichever skill was actually mid-session, so saying "stop" while a story was playing did nothing at all - the reading continued right through it. If you hit this, check whether ovos-skill-stop (or similar) is installed and whether it needs to be blacklisted; this looks like it may be a core stop_high skill-selection behavior worth raising upstream rather than something fixable from this plugin's side.

You'll also want at least one provider skill installed, otherwise there's nothing to read:

Building your own provider

See ovos-skill-common-reading-example - a template walking through two working patterns (RSS feeds and static-page scraping), the bus protocol (announce your vocabulary, or nothing reaches you), caching, and the judgment calls every provider has to make for itself (translate or not, what a human calls the source, what's worth reading aloud).

The ovos.common_reading.* bus protocol

Provider skills implement this to be usable by this plugin. It's a plain messagebus convention (like ovos.common_play.*) - no shared package dependency needed.

Broadcast on a matched utterance:

ovos.common_reading.search
{
  "phrase": "<what the user asked for, or null for 'surprise me'>",
  "collection_hint": "<raw text like 'grimm' or 'h c andersen', or null>",
  "content_type": "<the canonical kind of text asked for, as announced ('story', 'horoscope'), or null>",
  "lang": "<the language the request was made in, e.g. 'fr-FR'>",
  "requester": "<this plugin's id>"
}

The search (like the fetch and the ping below) is forwarded from the user's request, so its context carries the requesting session and route. Answer with message.reply(...): that keeps the session on the answer, and the plugin only takes answers for the session that asked (two users can search at the same moment on a hub).

lang is the language ovos-core matched the request in. Answer in that language, or not at all; don't take the language from the session instead (for the default session, SessionManager.get() returns the device's own language whatever the message carried).

collection_hint is set when the user names a specific source/collection ("read me a story from Grimm", "find Cinderella by Andersen"). It's raw, unvalidated text - each provider fuzzy-matches it against its own known friendly names and should only respond if it's a match, or if collection_hint is null (in which case every provider competes as usual).

content_type is a similarly raw, optional hint. A provider that only offers one kind of content can use it to stay silent when it clearly doesn't apply, but should treat a null/missing content_type as "anyone can compete".

phrase can also be null on its own - "read me a story from Grimm" with no specific title named is a valid request for the hinted provider to offer something of its own choosing.

"Tell me a story" ("raconte-moi une histoire", "erzähl mir ein Märchen", see locale/<lang>/ReadAnyStory.intent) sends phrase and collection_hint null and content_type "story", whatever language it was said in. A story provider answers it with one story of its own choosing at confidence 0.9: enough to be read without the "is it that one?" question, below the 1.0 of a title somebody named. When several providers answer, one of the equally confident answers is picked at random.

Every provider that thinks it can help replies (within ~2s):

ovos.common_reading.search.response
{
  "skill_id": "<provider skill id>",
  "content_id": "<opaque id the provider will recognize later>",
  "title": "<human-readable title>",
  "author": "<author, optional>",
  "collection": "<book/collection name, optional>",
  "source": "<where the text comes from, e.g. 'grimmstories.com'>",
  "confidence": 0.0-1.0,
  "machine_translated": true/false (optional, defaults falsy)
}

The highest-confidence response wins (and, if it's below 0.8, this plugin confirms with the user before continuing). If a provider sets "machine_translated": true, that's disclosed as part of the announcement right before reading starts.

title, author and collection are spoken as they are, in an announcement like "La Biche blanche, par Emmanuel Cosquin, tiré du recueil Contes populaires de Lorraine". source is said once, after the last sentence: "Et voilà, c'est la fin. C'était tiré de Project Gutenberg." A story that is stopped or paused doesn't get that line; it comes when the story is finished, after a "continue" if need be. The words around them come from this plugin's locale (by_author.dialog, from_collection.dialog, machine_translated.dialog, finished_reading.dialog), so give plain names ("Emmanuel Cosquin", not "collected by Emmanuel Cosquin"). content_id is only ever sent back to you, in the fetch, and keys the bookmark; it may differ from the title.

2. Fetch

Once something is chosen (or resumed via "continue"), a targeted request goes to just that provider:

ovos.common_reading.fetch_content.<provider_skill_id>
{"content_id": "<from the search response>", "lang": "<the language it was found in>",
 "requester": "<this plugin's id>"}

lang is the language of the search the story came from, also when "continue" was said in another language. The provider replies once, with message.reply(...) (only the answer for the requesting session is taken), with the full text, split into paragraphs:

ovos.common_reading.fetch_content.response
{"paragraphs": ["First paragraph...", "Second paragraph...", ...]}

Reading pacing, sentence splitting, and bookmark tracking all happen here - providers just deliver text.

Stop, pause, and continue

Reading checks a break condition between every sentence (not just between paragraphs), so an interruption takes effect within a sentence or so, not at the end of a whole paragraph. Two ways to interrupt:

  • "stop" - handled via stop_session(), the standard OVOS mechanism triggered by the platform's own stop command/button, for the session that said it. Not specific to this plugin's own vocabulary.
  • "pause" - a dedicated intent this plugin matches itself (see locale/<lang>/pause.intent), rather than relying on "pause" being recognized as a synonym for "stop" at the OVOS core level, which isn't guaranteed. Functionally identical to stop (same bookmark preservation), just with a dialog that explicitly invites resuming ("say continue when you're ready") instead of sounding final.

Either way, progress is bookmarked automatically - saying "continue" later picks up from the same sentence, even after a full restart, since the bookmark and the last-read content are both stored in persistent skill settings, not just in memory. All three only act on the session that said them.

3. Ping (only used when a search comes up empty)

If a search returns zero candidates, this plugin needs to say something honest - but "I couldn't find that" and "you don't have any reading skills installed" are very different situations, and guessing which one applies (or worse, silently falling back to some other language, see #2) is worse than just asking. So it asks:

ovos.common_reading.ping
{"lang": "<the language the request was made in>", "requester": "<this plugin's id>"}

A provider only answers a ping in a language it serves, with message.reply(...).

Every provider that's loaded and listening should reply, cheaply, with no index lookup:

ovos.common_reading.pong
{"skill_id": "<provider skill id>", "collection": "<same collection name it uses in search responses>"}

This is only broadcast on the rare 0-candidates path, never on every search - a normal search/fetch round trip never triggers a ping. Based on the pongs:

  • Zero pongs -> "you don't have any reading skills installed" (the most useful thing to tell the user, since it's actionable)
  • At least one pong, but nothing matched a collection_hint -> "I don't know a source called X"
  • At least one pong, but no phrase matched at all -> a generic "I couldn't find anything matching that"

This needs no language-awareness on the plugin's part: a provider that refused to load for the device's language (the SUPPORTED_LANGUAGES gate described below) never registers a ping handler either, so it correctly stays silent here too - the same mechanism that keeps it out of search results keeps it out of ping results, for free.

If you're building a provider, implementing this is required, not optional - a provider that never pongs looks, from the pipeline's perspective, exactly like a provider that isn't installed at all, which produces a misleading "nothing installed" message even when your skill is present and just didn't have a match. See ovos-skill-common-reading-example for the reference implementation.

4. Vocabulary

What a provider can read, in the words people use. Required: the pipeline only claims a request that names a kind of text, a collection or a title some provider announced (the story words are built in).

A provider sends one message per language it serves when it loads, and again when the pipeline asks (it does when it loads itself):

ovos.common_reading.vocabulary.get
{"langs": ["en-us", "da-dk"]}
ovos.common_reading.vocabulary
{
  "skill_id": "<provider skill id>",
  "lang": "en-us",
  "content_types": {"horoscope": ["horoscope", "horoscopes", "astrology"]},
  "collections": ["grimm", "brothers grimm", "the brothers grimm"],
  "titles": ["The Frog Prince", "Rapunzel"]
}
  • content_types: canonical name -> the words for it in this language, all inflections you want recognised ("horoskop", "horoskopet"). The canonical name is what the search carries as content_type. Story providers can leave it out: "story", "tale", "fairy tale" and their equivalents in the 8 languages are built in (canonical "story").
  • collections: the friendly names (below) in this language.
  • titles: optional. What the -low stage recognises as a bare title ("tell me the little mermaid"). Leave out titles that are also everyday phrases.

On shutdown a provider can send {"skill_id": ..., "remove": true} so its words stop being claimed.

Friendly names

Each provider should keep a small list of names it's willing to answer to as collection_hint - not just its skill_id, but the natural things a person might call it: ovos-skill-grimm-tales might match "grimm", "the brothers grimm"; ovos-skill-andersen-tales matches "andersen", "hans christian andersen", "h c andersen". Matching should be fuzzy (e.g. via ovos_utils.parse.match_one against the provider's own alias list) rather than exact string equality, since STT transcription is never perfectly consistent.

If collection_hint doesn't clearly match a provider's own aliases, that provider should simply not respond to the search at all - only providers that actually answered are considered.

Tune your match threshold carefully. A threshold around 0.6 can produce false positives on short strings (e.g. "andrew lang" scoring 0.63 against "andersen" on plain character overlap, nothing to do with meaning) - 0.85 is a safer default. Verify empirically for your own alias list.

Language handling

Every provider in this family handles the device's language one of two ways:

  • Fixed set of supported languages, no translation (ovos-skill-andersen-tales, ovos-skill-grimm-tales, ovos-skill-andrew-lang-tales): checks a SUPPORTED_LANGUAGES set at the top of initialize(), before building any index or registering any bus events. On an unsupported device language, the provider logs why and stays completely inert - never loads an index, never listens for ovos.common_reading.search - rather than loading fully and silently declining every search.
  • Machine translation (ovos-skill-ovosblog, ovos-skill-arxiv-papers): always loads (it can't know in advance whether a translation plugin will be configured), matches search phrases against translated titles, and declines per-search rather than per-load if no translator is available.

Neither pattern ever silently serves the wrong language - the difference is only when the provider decides it can't help (once, at load time, if it has no way to translate; or per-search, if it might). See ovos-skill-common-reading-example's module docstring (decision points #1 and #4) for the full reasoning behind picking one over the other for your own provider.

Category

Entertainment

Tags

#reading #stories #articles #news #orchestrator #pipeline

Metadata

Release files for ovos-common-reading-pipeline-plugin 0.3.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for ovos-common-reading-pipeline-plugin 0.3.0
File Size Uploaded
ovos_common_reading_pipeline_plugin-0.3.0.tar.gz 114.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for ovos-common-reading-pipeline-plugin 0.3.0
File Interpreter ABI Platform
ovos_common_reading_pipeline_plugin-0.3.0-py3-none-any.whl Python 3 none any Details

Total release size: 224.1 kB

Release files / ovos_common_reading_pipeline_plugin-0.3.0.tar.gz

Download URL ovos_common_reading_pipeline_plugin-0.3.0.tar.gz
Size 114.7 kB
Tags Source
SHA-256 checksum
How to use checksums
9e175605ad877f83b193a388116ae823e24c871d4274a3c0e428f3b4968bcdb8
BLAKE2b-256 checksum
How to use checksums
3f893b842f762a392dd78a98e26716f617d13eeaa8bdb92a0424bf4530432b30
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 30, 2026.

Transparency log

Release files / ovos_common_reading_pipeline_plugin-0.3.0-py3-none-any.whl

Download URL ovos_common_reading_pipeline_plugin-0.3.0-py3-none-any.whl
Size 109.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
a714e4ab54af09186b012c8924fdd67b66d40731634a4e4afcc9d0a23a0c5ccb
BLAKE2b-256 checksum
How to use checksums
0531cca32690c2305b007f95bf96536718f57aa75075915c30a6f46cef4b8207
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 30, 2026.

Transparency log

Release history Release notifications | RSS feed

0.3.2

2 release files

0.3.1

2 release files

This release

0.3.0 This release

2 release files

0.2.0

2 release files

0.1.13

2 release files

0.1.11

2 release files

0.1.10

2 release files

0.1.9

2 release files

0.1.8

2 release files

0.1.7

2 release files

0.1.6

2 release files

0.1.5

2 release files

0.1.4

2 release files

0.1.3

2 release files

0.1.2

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page