<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
    <title>steinbro.github.io</title>
    <subtitle>Content by Daniel W. Steinbrook</subtitle>
    <link rel="self" type="application/atom+xml" href="https://steinbro.github.io/atom.xml"/>
    <link rel="alternate" type="text/html" href="https://steinbro.github.io"/>
    <generator uri="https://www.getzola.org/">Zola</generator>
    <updated>2026-08-09T00:00:00+00:00</updated>
    <id>https://steinbro.github.io/atom.xml</id>
    <entry xml:lang="en">
        <title>Let&#x27;s snap Spiel</title>
        <published>2026-08-09T00:00:00+00:00</published>
        <updated>2026-08-09T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              steinbro
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://steinbro.github.io/blog/spiel-snaps/"/>
        <id>https://steinbro.github.io/blog/spiel-snaps/</id>
        
        <content type="html" xml:base="https://steinbro.github.io/blog/spiel-snaps/">&lt;p&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;project-spiel.org&#x2F;&quot;&gt;Spiel&lt;&#x2F;a&gt; is a proposed replacement for the aging speech-dispatcher service on Linux. It’s essentially the glue between applications that need to generate speech, like screen readers, and text-to-speech engines like &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;rhasspy.github.io&#x2F;piper-samples&#x2F;&quot;&gt;Piper&lt;&#x2F;a&gt; that can generate it.&lt;&#x2F;p&gt;
&lt;p&gt;One selling point of Spiel over speech-dispatcher is that its architecture is better suited with modern sandboxed apps like snaps. But as far as I can tell, Spiel’s distribution has been limited to flatpaks. Let’s fix that.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;using-snap-interfaces&quot;&gt;Using snap interfaces&lt;&#x2F;h3&gt;
&lt;p&gt;Spiel’s architecture puts TTS engines in their own processes, called speech providers, that communicate with client applications via D-Bus. When new speech providers are installed, any application using Spiel libraries can find it automatically using D-Bus service discovery.&lt;&#x2F;p&gt;
&lt;p&gt;Snaps support this kind of interface through &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;snapcraft.io&#x2F;docs&#x2F;explanation&#x2F;interfaces&#x2F;all-about-interfaces&#x2F;&quot;&gt;plugs and slots&lt;&#x2F;a&gt;. The speech provider snap defines a D-Bus service, and the snap’s executable entrypoint is configured to activate based on that connection. This means the speech provider daemon will only run when an application triggers it via a D-Bus message.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #CDD6F4; background-color: #1E1E2E;&quot;&gt;&lt;code data-lang=&quot;yaml&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9399B2;font-style: italic;&quot;&gt;# speech-provider-piper snap&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #89B4FA;&quot;&gt;slots&lt;&#x2F;span&gt;&lt;span style=&quot;color: #94E2D5;&quot;&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #89B4FA;&quot;&gt;  speech-provider&lt;&#x2F;span&gt;&lt;span style=&quot;color: #94E2D5;&quot;&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #89B4FA;&quot;&gt;    interface&lt;&#x2F;span&gt;&lt;span style=&quot;color: #94E2D5;&quot;&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #A6E3A1;&quot;&gt; dbus&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #89B4FA;&quot;&gt;    bus&lt;&#x2F;span&gt;&lt;span style=&quot;color: #94E2D5;&quot;&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #A6E3A1;&quot;&gt; session&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #89B4FA;&quot;&gt;    name&lt;&#x2F;span&gt;&lt;span style=&quot;color: #94E2D5;&quot;&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #A6E3A1;&quot;&gt; ai.piper.Speech.Provider&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #89B4FA;&quot;&gt;apps&lt;&#x2F;span&gt;&lt;span style=&quot;color: #94E2D5;&quot;&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #89B4FA;&quot;&gt;  speech-provider-piper&lt;&#x2F;span&gt;&lt;span style=&quot;color: #94E2D5;&quot;&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #89B4FA;&quot;&gt;    command&lt;&#x2F;span&gt;&lt;span style=&quot;color: #94E2D5;&quot;&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #A6E3A1;&quot;&gt; usr&#x2F;local&#x2F;bin&#x2F;speech-provider-piper&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #89B4FA;&quot;&gt;    daemon&lt;&#x2F;span&gt;&lt;span style=&quot;color: #94E2D5;&quot;&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #A6E3A1;&quot;&gt; simple&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #89B4FA;&quot;&gt;    daemon-scope&lt;&#x2F;span&gt;&lt;span style=&quot;color: #94E2D5;&quot;&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #A6E3A1;&quot;&gt; user&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #89B4FA;&quot;&gt;    activates-on&lt;&#x2F;span&gt;&lt;span style=&quot;color: #94E2D5;&quot;&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9399B2;&quot;&gt; [&lt;&#x2F;span&gt;&lt;span style=&quot;color: #A6E3A1;&quot;&gt;speech-provider&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9399B2;&quot;&gt;]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;A client application like Orca would define a corresponding plug with the same service name:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #CDD6F4; background-color: #1E1E2E;&quot;&gt;&lt;code data-lang=&quot;yaml&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9399B2;font-style: italic;&quot;&gt;# orca snap&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #89B4FA;&quot;&gt;plugs&lt;&#x2F;span&gt;&lt;span style=&quot;color: #94E2D5;&quot;&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #89B4FA;&quot;&gt;  speech-provider-piper&lt;&#x2F;span&gt;&lt;span style=&quot;color: #94E2D5;&quot;&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #89B4FA;&quot;&gt;    interface&lt;&#x2F;span&gt;&lt;span style=&quot;color: #94E2D5;&quot;&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #A6E3A1;&quot;&gt; dbus&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #89B4FA;&quot;&gt;    bus&lt;&#x2F;span&gt;&lt;span style=&quot;color: #94E2D5;&quot;&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #A6E3A1;&quot;&gt; session&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #89B4FA;&quot;&gt;    name&lt;&#x2F;span&gt;&lt;span style=&quot;color: #94E2D5;&quot;&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #A6E3A1;&quot;&gt; ai.piper.Speech.Provider&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Once both snaps are installed, users can hook up the application to the speech provider manually:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #CDD6F4; background-color: #1E1E2E;&quot;&gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;$ snap connect orca:speech-provider-piper speech-provider-piper:speech-provider&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;h3 id=&quot;defining-voice-packs&quot;&gt;Defining voice packs&lt;&#x2F;h3&gt;
&lt;p&gt;There’s a third layer of packaging required here, beyond the speech providers and client applications: voices. TTS engines typically support many languages, with potentially many voice options for each. For a modern TTS engine like Piper, an installed voice can use hundreds of megabytes of disk space for the underlying AI model. It’s therefore best to let users choose which subset of voices they’d like to install.&lt;&#x2F;p&gt;
&lt;p&gt;Snaps have a couple different concepts related to packaging content separately from an application. &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;snapcraft.io&#x2F;docs&#x2F;reference&#x2F;interfaces&#x2F;content-interface&#x2F;&quot;&gt;Content snaps&lt;&#x2F;a&gt; are published independently from the snaps they support, potentially even by a different author, and use interfaces to share resources. They’re most valuable when the packaged content is used by multiple snaps, such as &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ubuntu&#x2F;gnome-sdk&quot;&gt;GNOME runtimes&lt;&#x2F;a&gt;. Since the Piper voice models will only be used by the speech provider snap, this isn’t the case here.&lt;&#x2F;p&gt;
&lt;p&gt;On the other hand, &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;snapcraft.io&#x2F;docs&#x2F;explanation&#x2F;how-snaps-work&#x2F;snap-components&#x2F;&quot;&gt;components&lt;&#x2F;a&gt; are designed to package optional parts of a single application. This is the technique used by other snaps that have selectively-installable ML models, such as &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;canonical&#x2F;gemma4-snap&quot;&gt;gemma4&lt;&#x2F;a&gt;. Components are also much faster to build than separate content snaps, because they are all built in the same build container as the snap they support. Since our Piper speech provider will have dozens of supported languages and locales, this can make a big difference here.&lt;&#x2F;p&gt;
&lt;p&gt;Defining components involves simply including a top-level &lt;code&gt;components&lt;&#x2F;code&gt; key and a part that writes the desired data into the component’s directory. For Piper voices, we download the voice model and its metadata individually, because cloning the whole repository of voices would incur several gigabytes of network traffic and disk usage.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #CDD6F4; background-color: #1E1E2E;&quot;&gt;&lt;code data-lang=&quot;yaml&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9399B2;font-style: italic;&quot;&gt;# speech-provider-piper snap&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #89B4FA;&quot;&gt;components&lt;&#x2F;span&gt;&lt;span style=&quot;color: #94E2D5;&quot;&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #89B4FA;&quot;&gt;  voices-en-gb-alan-medium&lt;&#x2F;span&gt;&lt;span style=&quot;color: #94E2D5;&quot;&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #89B4FA;&quot;&gt;    type&lt;&#x2F;span&gt;&lt;span style=&quot;color: #94E2D5;&quot;&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #A6E3A1;&quot;&gt; standard&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #89B4FA;&quot;&gt;    summary&lt;&#x2F;span&gt;&lt;span style=&quot;color: #94E2D5;&quot;&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #A6E3A1;&quot;&gt; en-GB alan voice for Piper&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #89B4FA;&quot;&gt;    description&lt;&#x2F;span&gt;&lt;span style=&quot;color: #94E2D5;&quot;&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #A6E3A1;&quot;&gt; en-GB alan voice for Piper&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #89B4FA;&quot;&gt;    version&lt;&#x2F;span&gt;&lt;span style=&quot;color: #94E2D5;&quot;&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #A6E3A1;&quot;&gt; &amp;#39;0.1&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #89B4FA;&quot;&gt;parts&lt;&#x2F;span&gt;&lt;span style=&quot;color: #94E2D5;&quot;&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #89B4FA;&quot;&gt;  voices-en-gb-alan-medium&lt;&#x2F;span&gt;&lt;span style=&quot;color: #94E2D5;&quot;&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #89B4FA;&quot;&gt;    plugin&lt;&#x2F;span&gt;&lt;span style=&quot;color: #94E2D5;&quot;&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #A6E3A1;&quot;&gt; dump&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #89B4FA;&quot;&gt;    source&lt;&#x2F;span&gt;&lt;span style=&quot;color: #94E2D5;&quot;&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #A6E3A1;&quot;&gt; .&#x2F;component-base&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #89B4FA;&quot;&gt;    build-packages&lt;&#x2F;span&gt;&lt;span style=&quot;color: #94E2D5;&quot;&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9399B2;&quot;&gt;      -&lt;&#x2F;span&gt;&lt;span style=&quot;color: #A6E3A1;&quot;&gt; curl&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9399B2;&quot;&gt;      -&lt;&#x2F;span&gt;&lt;span style=&quot;color: #A6E3A1;&quot;&gt; ca-certificates&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #89B4FA;&quot;&gt;    override-build&lt;&#x2F;span&gt;&lt;span style=&quot;color: #94E2D5;&quot;&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #CBA6F7;&quot;&gt; |&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #A6E3A1;&quot;&gt;      craftctl default&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #A6E3A1;&quot;&gt;      set -eu&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #A6E3A1;&quot;&gt;      mkdir -p &amp;quot;$CRAFT_PART_INSTALL&#x2F;en_GB-alan-medium&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #A6E3A1;&quot;&gt;      curl -L --fail --retry 3 \&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #A6E3A1;&quot;&gt;        -o &amp;quot;$CRAFT_PART_INSTALL&#x2F;en_GB-alan-medium&#x2F;en_GB-alan-medium.onnx&amp;quot; \&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #A6E3A1;&quot;&gt;        &amp;quot;https:&#x2F;&#x2F;huggingface.co&#x2F;rhasspy&#x2F;piper-voices&#x2F;resolve&#x2F;main&#x2F;en&#x2F;en_GB&#x2F;alan&#x2F;medium&#x2F;en_GB-alan-medium.onnx&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #A6E3A1;&quot;&gt;      curl -L --fail --retry 3 \&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #A6E3A1;&quot;&gt;        -o &amp;quot;$CRAFT_PART_INSTALL&#x2F;en_GB-alan-medium&#x2F;en_GB-alan-medium.onnx.json&amp;quot; \&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #A6E3A1;&quot;&gt;        &amp;quot;https:&#x2F;&#x2F;huggingface.co&#x2F;rhasspy&#x2F;piper-voices&#x2F;resolve&#x2F;main&#x2F;en&#x2F;en_GB&#x2F;alan&#x2F;medium&#x2F;en_GB-alan-medium.onnx.json&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #89B4FA;&quot;&gt;    organize&lt;&#x2F;span&gt;&lt;span style=&quot;color: #94E2D5;&quot;&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #89B4FA;&quot;&gt;      en_GB-alan-medium&#x2F;en_GB-alan-medium.onnx&lt;&#x2F;span&gt;&lt;span style=&quot;color: #94E2D5;&quot;&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #A6E3A1;&quot;&gt; (component&#x2F;voices-en-gb-alan-medium)&#x2F;en_GB-alan-medium.onnx&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #89B4FA;&quot;&gt;      en_GB-alan-medium&#x2F;en_GB-alan-medium.onnx.json&lt;&#x2F;span&gt;&lt;span style=&quot;color: #94E2D5;&quot;&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #A6E3A1;&quot;&gt; (component&#x2F;voices-en-gb-alan-medium)&#x2F;en_GB-alan-medium.onnx.json&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;h3 id=&quot;next-steps&quot;&gt;Next steps&lt;&#x2F;h3&gt;
&lt;p&gt;The Orca screen reader has &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;GNOME&#x2F;orca&#x2F;blob&#x2F;main&#x2F;README.md#spiel-text-to-speech-support&quot;&gt;experimental support for Spiel&lt;&#x2F;a&gt;, and it could take advantage of these packages on systems that support snaps. Orca itself wouldn’t need to be packaged as a snap to do so, though having an Orca snap would make installation convenient. However, Orca’s unusual interaction patterns could make it tricky to run as a sandboxed app.&lt;&#x2F;p&gt;
&lt;p&gt;You can find snapcraft.yaml build configuration files and testing scripts &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;steinbro&#x2F;spiel-snaps&quot;&gt;on GitHub&lt;&#x2F;a&gt;. Try it out, and add a few more languages and voices to the Piper speech provider while you’re at it.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Audio annotations for the real world</title>
        <published>2026-06-19T00:00:00+00:00</published>
        <updated>2026-06-19T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              steinbro
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://steinbro.github.io/blog/real-world-screen-reader/"/>
        <id>https://steinbro.github.io/blog/real-world-screen-reader/</id>
        
        <content type="html" xml:base="https://steinbro.github.io/blog/real-world-screen-reader/">&lt;h3 id=&quot;augmenting-reality-with-standard-maps&quot;&gt;Augmenting reality with standard maps&lt;&#x2F;h3&gt;
&lt;p&gt;Cartography dates back to a time when the most efficient way to describe a place was to draw it on paper. In more recent years, we’ve had the ability to layer map information on top of the real world, thanks to our smartphones. Your phone knows what’s around you because it knows your exact GPS location together with information about all nearby venues, at least those that have been captured in online maps.&lt;&#x2F;p&gt;
&lt;p&gt;I remember this idea showing up as a feature on Windows Phone over a decade ago, through an app called Nokia City Lens. You could hold up your phone to a city street, and it would add floating labels on top of all the places it knew about.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;https:&#x2F;&#x2F;steinbro.github.io&#x2F;blog&#x2F;real-world-screen-reader&#x2F;.&#x2F;citylens.webp&quot; alt=&quot;Nokia City Lens showing points of interest&quot; &#x2F;&gt;
(source: &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;www.wired.com&#x2F;2012&#x2F;05&#x2F;hands-on-nokia-city-lens-beta-augmented-reality-for-your-lumia-phone&#x2F;&quot;&gt;WIRED&lt;&#x2F;a&gt;)&lt;&#x2F;p&gt;
&lt;p&gt;Less well-publicized was a Microsoft research project called Soundscape, which  did this reality augmentation as audio rather than video. Instead of drawing labels on top of a camera view, your phone speaks markers as spatial audio. Even with basic headphones, you hear the labels as if they emanate from different places and directions.&lt;&#x2F;p&gt;
&lt;p&gt;This turns out to be a pretty handy navigational aid, especially if you can’t easily read signs. Walking down the street with earbuds in and phone in your pocket, you hear the names of places around you as you approach them, with a decent sense of exactly where they are.&lt;&#x2F;p&gt;
&lt;div &gt;
    &lt;iframe style=&quot;width:100%; aspect-ratio:16&#x2F;9;&quot; 
        src=&quot;https:&#x2F;&#x2F;www.youtube.com&#x2F;embed&#x2F;v5DXykmOdJ8&quot;
        webkitallowfullscreen
        mozallowfullscreen
        allowfullscreen&gt;
    &lt;&#x2F;iframe&gt;
&lt;&#x2F;div&gt;&lt;h3 id=&quot;community-driven-mobility-tools&quot;&gt;Community-driven mobility tools&lt;&#x2F;h3&gt;
&lt;p&gt;Nifty as this technology is, there wasn’t much commercial potential, and &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;www.microsoft.com&#x2F;en-us&#x2F;research&#x2F;blog&#x2F;microsoft-soundscape-new-horizons-with-a-community-driven-approach&#x2F;&quot;&gt;Microsoft discontinued Soundscape&lt;&#x2F;a&gt; in 2023. To satisfy the dedicated fanbase among low-vision users, they first &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;microsoft&#x2F;soundscape&quot;&gt;dumped the source code&lt;&#x2F;a&gt; on GitHub, under an open-source license. After some initial concern as to whether anyone would step up to run the servers and republish the app, no fewer than three different groups picked it up. Each has found their own niche to serve.&lt;&#x2F;p&gt;
&lt;p&gt;A solo developer published &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;apps.apple.com&#x2F;us&#x2F;app&#x2F;voicevista&#x2F;id6450388413&quot;&gt;VoiceVista&lt;&#x2F;a&gt;, a closed-source fork that adds all sorts of bells and whistles, though it’s hard to tell exactly what’s been added due to limited documentation. Separately, a Scottish nonprofit published a long-requested &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;play.google.com&#x2F;store&#x2F;apps&#x2F;details?id=org.scottishtecharmy.soundscape&amp;amp;hl=en_US&quot;&gt;Android version&lt;&#x2F;a&gt;, which was no small feat. And finally, there is &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;soundscape.services&#x2F;&quot;&gt;Soundscape Community&lt;&#x2F;a&gt;, the initiative I helped get off the ground and spent several years as a volunteer developer. They’ve established an open source community of contributors and users, and have been finding new uses and collaborations in adaptive sports, among others&lt;&#x2F;p&gt;
&lt;h3 id=&quot;what-makes-soundscape-special&quot;&gt;What makes Soundscape special&lt;&#x2F;h3&gt;
&lt;p&gt;It’s hard to pinpoint what makes the Soundscape app feel so empowering, compared to audio turn-by-turn directions that are commonplace. Perhaps it’s the strong sense of agency: Soundscape is not telling you where to go – you follow whatever path you want, and it dutifully speaks up about what’s around you, when it’s most relevant.&lt;&#x2F;p&gt;
&lt;p&gt;Soundscape is also unusual assistive tech because it’s providing a visual aid without using any visual input. It relies entirely on GPS position and compass heading, not the camera. This makes it feel a bit like a spatial superpower. It can tell you the name of a park that has no marker, or the name of a street that has no sign. This makes it a natural complement to smart glasses that can describe a visual scene.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>An unobtrusive text-to-speech workflow in GNOME</title>
        <published>2026-06-06T00:00:00+00:00</published>
        <updated>2026-06-06T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              steinbro
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://steinbro.github.io/blog/speak-selected-text-on-linux/"/>
        <id>https://steinbro.github.io/blog/speak-selected-text-on-linux/</id>
        
        <content type="html" xml:base="https://steinbro.github.io/blog/speak-selected-text-on-linux/">&lt;p&gt;With assistive tech, software ergonomics matter. When the user relies on a capability all day, every day, it doesn’t just need to work; it needs to be frictionless. When that feature is text-to-speech, it should be available instantly, for any text on the screen. The operating system, or more specifically the desktop environment, can step in to provide it system-wide, across all applications. For Linux, this functionality (as with much else on Linux) is possible, with some tinkering.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;on-demand-speech-on-other-operating-systems&quot;&gt;On-demand speech on other operating systems&lt;&#x2F;h3&gt;
&lt;p&gt;macOS has had &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;support.apple.com&#x2F;en-ca&#x2F;guide&#x2F;mac-help&#x2F;mh27448&#x2F;mac&quot;&gt;“Speak Selection” functionality&lt;&#x2F;a&gt; for ages. When actively speaking, the system shows a floating controller for playback and speech rate adjustment.
&lt;img src=&quot;https:&#x2F;&#x2F;photos5.appleinsider.com&#x2F;gallery&#x2F;43696-85254-macos-speech-tip-3-xl.jpg&quot; alt=&quot;Speech controller on macOS&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Microsoft similarly introduced &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;blogs.windows.com&#x2F;windowsexperience&#x2F;2020&#x2F;05&#x2F;21&#x2F;whats-coming-in-windows-10-accessibility&#x2F;&quot;&gt;speech features into Magnifier&lt;&#x2F;a&gt; in Windows 10. Clicking the “Read from here” button starts reading text aloud from wherever the user specifies.
&lt;img src=&quot;https:&#x2F;&#x2F;winblogs.thesourcemediaassets.com&#x2F;sites&#x2F;2&#x2F;2020&#x2F;05&#x2F;343dbcf643139fc55528c962b84b8da0.png&quot; alt=&quot;Windows Magnifier controls&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;The &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;support.mozilla.org&#x2F;en-US&#x2F;kb&#x2F;firefox-reader-view-clutter-free-web-pages&quot;&gt;Reader View in Firefox&lt;&#x2F;a&gt; has text-to-speech built in as well.
&lt;img src=&quot;https:&#x2F;&#x2F;assets-prod.sumo.prod.webservices.mozgcp.net&#x2F;media&#x2F;uploads&#x2F;gallery&#x2F;images&#x2F;2024-08-06-05-16-50-40e344.png&quot; alt=&quot;Speech controls in Firefox Reader View&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Screen readers also have some features that are geared towards partially-sighted users. Both NVDA on Windows and VoiceOver on macOS support a mode that reads text under the mouse pointer. But on Linux, &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;discourse.gnome.org&#x2F;t&#x2F;mouse-review-is-hit-and-miss&#x2F;34949&quot;&gt;Orca’s Mouse Review feature works inconsistently under Wayland&lt;&#x2F;a&gt; the modern display system for Linux.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;prior-art-on-linux&quot;&gt;Prior art on Linux&lt;&#x2F;h3&gt;
&lt;p&gt;A &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;dev.to&#x2F;tylerlwsmith&#x2F;read-selected-text-out-loud-on-ubuntu-linux-45lj&quot;&gt;2021 blog post&lt;&#x2F;a&gt; demonstrates how to set up a keyboard shortcut to trigger text-to-speech on most any Linux desktop environment. The impressively short shell script simply grabs the highlighted text from the clipboard’s primary selection buffer, and feeds that directly into the espeak synthesizer.&lt;&#x2F;p&gt;
&lt;p&gt;This works great much of the time, but you’ll run into many unhandled edge cases if you use it extensively. For example. text containing Unicode mathematical characters, a common social media trick for adding styling, can turn into a stream of arcane code point names. “𝐉𝐚𝐯𝐚𝐒𝐜𝐫𝐢𝐩𝐭” becomes “letter 1D409, letter 1D41A, …” Emoji fare even worse, often disappearing entirely from the spoken output.&lt;&#x2F;p&gt;
&lt;p&gt;My favorite failure mode is when the script encounters text surrounded by double square brackets. The espeak synthesizer interprets what’s inside as raw phonetic data. The result is text that is a very exotic flavor of garbled. [[ If you happen to be using it now, this example will demonstrate exactly what I  mean,. ]]&lt;&#x2F;p&gt;
&lt;h3 id=&quot;a-native-extension-for-gnome&quot;&gt;A native extension for GNOME&lt;&#x2F;h3&gt;
&lt;p&gt;I’ve published a &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;extensions.gnome.org&#x2F;extension&#x2F;9659&#x2F;speak-selection&#x2F;&quot;&gt;GNOME shell extension&lt;&#x2F;a&gt; that builds on the same basic approach to capturing and speaking selected text, but makes it a bit more robust to text encountered in the wild.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;https:&#x2F;&#x2F;steinbro.github.io&#x2F;blog&#x2F;speak-selected-text-on-linux&#x2F;speak_selection_extension_tray_icon_menu.png&quot; alt=&quot;Speak Selection extension tray icon menu&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;The most visible improvement is its integration with the GNOME desktop, but the bigger change comes from inserting Speech Dispatcher between your text and the speech engine. This long-standing Linux service handles many of the failure modes we encountered earlier: it translates Unicode characters into something meaningful, announces emoji names instead of silently dropping them, and filters out control sequences that can trip up speech synthesizers.&lt;&#x2F;p&gt;
&lt;p&gt;Because Speech Dispatcher operates as a system-wide service, it also improves the overall listening experience. Other applications won’t try to speak over the extension, and you can change your preferred voice or speaking rate without needing to edit the values hard-coded into the original script.&lt;&#x2F;p&gt;
&lt;p&gt;There are still some limitations. Most significantly, the extension still relies on the primary selection buffer, a legacy feature of the Linux desktop that isn’t supported universally. For example, applications that are based on the Flutter toolkit, including the Ubuntu App Center and FluffyChat, &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;flutter&#x2F;flutter&#x2F;issues&#x2F;180231&quot;&gt;don’t populate this buffer&lt;&#x2F;a&gt; when the user highlights text. That means the extension will ignore what you’ve highlighted in your Flutter-based app, and instead speak the last text highlighted in some other non-Flutter-based application.&lt;&#x2F;p&gt;
&lt;p&gt;Nevertheless, give it a try and let me know if you find it helpful!&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Adapting magnifier controls for mouse-centric websites</title>
        <published>2026-05-25T00:00:00+00:00</published>
        <updated>2026-05-25T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              steinbro
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://steinbro.github.io/blog/hover-magnifier-interactions/"/>
        <id>https://steinbro.github.io/blog/hover-magnifier-interactions/</id>
        
        <content type="html" xml:base="https://steinbro.github.io/blog/hover-magnifier-interactions/">&lt;h3 id=&quot;the-problem&quot;&gt;The problem&lt;&#x2F;h3&gt;
&lt;p&gt;Modern websites love using hover effects. Pausing the mouse pointer over various parts of the page can trigger preview cards, floating menus, or something else entirely.&lt;&#x2F;p&gt;
&lt;p&gt;For most users, this is fine and sometimes even helpful. If it’s unwanted, the user can just move the mouse elsewhere. But it poses an accessibility challenge for full-screen magnification users. Their visible area of the page, known as a viewport, will be much smaller than what most other users have.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;https:&#x2F;&#x2F;steinbro.github.io&#x2F;blog&#x2F;hover-magnifier-interactions&#x2F;laptop-full-screen-zoom.png&quot; alt=&quot;Ubuntu laptop with full-screen magnification&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Many hover-heavy interfaces are built around a pair of assumptions:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Moving the mouse over something means the user wants to interact with it more deeply.&lt;&#x2F;li&gt;
&lt;li&gt;Users can comfortably look at one part of the screen while manipulating another.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;For full-screen magnification users, neither assumption really holds. Moving the mouse is often just a way to pan the viewport or keep content visible, not an intentional request for more UI. And because only a small portion of the screen is visible at once, users frequently cannot see the area they are interacting with and the area where the resulting information appears at the same time.&lt;&#x2F;p&gt;
&lt;h4 id=&quot;obscured-content&quot;&gt;Obscured content&lt;&#x2F;h4&gt;
&lt;p&gt;At high zoom levels, the visible portion of the screen is tiny, and the magnified area usually follows the mouse pointer. So when a website decides to reveal extra information on hover, that information can end up covering the very thing the user was trying to look at.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;https:&#x2F;&#x2F;lh4.googleusercontent.com&#x2F;bPcgol-gx6iVl5VbH3X3GOznPHMEludSLFxyRw0sk1eyKv7rlheRbF1I_fDKjZMG7VrnfDxud7VfOTtmfLBVdKgeSwY-SGyNfoICjbH5n7X9Rcob1mK8Kt7K9lRjBWULRkBDCm-d=s0&quot; alt=&quot;A link preview in Google Sheets covering most of the screen&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;The obvious solution, moving the mouse elsewhere, doesn’t really work. Moving the pointer also moves the zoomed viewport, which can push the content completely off-screen.&lt;&#x2F;p&gt;
&lt;h4 id=&quot;important-information-outside-the-viewport&quot;&gt;Important information outside the viewport&lt;&#x2F;h4&gt;
&lt;p&gt;In some cases, the problem is spatial rather than transient. An interactive graph, for example, might show numerical values in a fixed corner of the screen while the user moves the mouse across the graph. Under full-screen magnification, the user may be able to see the graph or the readout, but not both at the same time. Moving the mouse to explore the graph also moves the zoomed viewport, making it difficult or impossible to keep the changing values visible.&lt;&#x2F;p&gt;
&lt;video width=&quot;100%&quot; controls&gt;
  &lt;source src=&quot;graph-values-offscreen.mp4&quot; type=&quot;video&#x2F;mp4&quot;&gt;
  Your browser does not support the video tag.
&lt;&#x2F;video&gt;
&lt;p&gt;The root problem is that, for magnification users, mouse movement is doing two completely different jobs at the same time. It’s being used both to indicate what they want to interact with, and to control which part of the screen they can see.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-solution&quot;&gt;The solution&lt;&#x2F;h3&gt;
&lt;p&gt;Fortunately, these problems don’t require changes to every offending site. We can introduce some small tweaks on the user side to decouple pointer interaction from panning the viewport.&lt;&#x2F;p&gt;
&lt;p&gt;In macOS, this kind of functionality is built-in. One of the &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;support.apple.com&#x2F;guide&#x2F;mac-help&#x2F;change-zoom-advanced-options-accessibility-mh35715&#x2F;26&#x2F;mac&#x2F;26&quot;&gt;“advanced options” of the full-screen magnifier&lt;&#x2F;a&gt; is “Detach zoom view from pointer.” When the user holds down the Control and Command keys, the mouse pointer is effectively frozen in place. Moving the mouse moves the zoomed area, but the focused application doesn’t register any mouse movement.&lt;&#x2F;p&gt;
&lt;h4 id=&quot;freezing-the-pointer&quot;&gt;Freezing the pointer&lt;&#x2F;h4&gt;
&lt;p&gt;With some AI assistance, I’ve replicated this as a &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;steinbro&#x2F;gnome-low-vis-config&#x2F;blob&#x2F;main&#x2F;userscripts&#x2F;A11yFreezePointerOnCtrl.js&quot;&gt;“freeze pointer” user script&lt;&#x2F;a&gt;, which can be installed in a Web browser through an extension like &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;www.tampermonkey.net&#x2F;&quot;&gt;Tampermonkey&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Below, as illustrated earlier, the user is moving the mouse across a graph while the exact values appear in a fixed location outside the magnified area. With the frozen-pointer user script, however, the user can hold down the Control key to temporarily freeze the hover position on a single data point. This enables them to pan the magnified viewport until the readout is visible again, without changing which data point is selected under the mouse pointer.&lt;&#x2F;p&gt;
&lt;video width=&quot;100%&quot; controls&gt;
  &lt;source src=&quot;freeze-pointer-demo.mp4&quot; type=&quot;video&#x2F;mp4&quot;&gt;
  Your browser does not support the video tag.
&lt;&#x2F;video&gt;
&lt;h4 id=&quot;shielding-the-page-from-interaction&quot;&gt;Shielding the page from interaction&lt;&#x2F;h4&gt;
&lt;p&gt;I also created a second user script that &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;steinbro&#x2F;gnome-low-vis-config&#x2F;blob&#x2F;main&#x2F;userscripts&#x2F;A11yDisableHoverOnCtrl.js&quot;&gt;temporarily adds an “invisible wall” between the pointer and the web page&lt;&#x2F;a&gt;. This takes a more aggressive, but also more reliable, approach: instead of freezing the current hover state in place, it prevents the page from receiving pointer interactions altogether while the modifier key is held. The downside is that it disables hover behavior entirely rather than preserving the currently displayed item or tooltip, so it’s less helpful in cases like the interactive graph.&lt;&#x2F;p&gt;
</content>
        
    </entry>
</feed>
