HearAble

Technical reference · version 1.5.2

HearAble internals

Exactly how live Italian subtitles get from a broadcast transport stream onto a television screen, component by component — the system as it runs, with every file that makes it up and every file it creates on the two machines.

01

The shape

Two machines. Compressed audio goes one way, two lines of text come back. Nothing else crosses the wire.

DECODER · ENIGMA2 frontend1 DVB-T2 → demux0 PID filter TSDEMUX_TAP ffmpeg -c copy → TCP wsserver :8770/hearable session one pending OSD · 25 fps MINI PC · DEBIAN relay_source :9010 listen AAC → PCM 16 kHz mono Nemotron 160 ms chunks stabiliser C_tail1 two-line 40 chars × 2 26 kB/s MPEG-TS subtitle_state private link 10.77.0.0/24 · no router, no DHCP, no name resolution
Two loops that never block each other: audio is pushed by the box and text is pushed by the mini PC, each over its own connection. The control channel that arranges the audio is a third, polled connection not drawn here.

The division of labour follows from what each machine can do. The receiver has the signal and the screen but a 4.4 kernel, a few hundred megabytes of flash and no room for a speech model. The mini PC has four x86 cores and cannot draw a pixel on the television. So the receiver taps and paints; the mini PC listens and recognises; and the link between them carries as little as possible.

02

From button to subtitle

The full sequence, in order, with the component responsible for each step.

Box runs on the receiver PC runs on the mini PC

  1. Box plugin.py · WHERE_SESSIONSTART

    Enigma2 starts and loads the plugin

    The plugin instantiates its dialog, binds three remote-control keys through a global action map, opens its WebSocket and control server on port 8770, and starts a 20 ms poll loop and a 40 ms render loop. HearAble is off. That state is not persisted on purpose: subtitles are something the viewer asks for.

  2. Box plugin.py → relay.py

    F4 is pressed

    Key code 62 arrives. The plugin paints the greeting for four seconds, marks the relay enabled, and increments an epoch — a counter that invalidates everything from the previous session, so nothing stale can arrive later and be painted. In the same action it sends a Wake-on-LAN magic packet to the mini PC's MAC, broadcast to 10.77.0.255 on UDP ports 9 and 7, repeating up to fifteen times at twenty-second intervals.

  3. PC hearable-t9.service

    The mini PC wakes and starts the pipeline

    The NIC wakes the machine in about 32 seconds. systemd brings up hearable-governor first — it is a hard dependency, not a nicety — then launches scripts/hearable_t9_live.sh, which execs python3 -m hearable.realtime_server with the private-link source and sink configurations.

  4. PC sf8008/relay_source.py

    The receiver announces itself

    The mini PC opens a listening socket on port 9010 and starts polling the box's control endpoint every 500 ms:

    GET http://10.77.0.1:8770/control?receiver=10.77.0.2%3A9010
        &want_lead_ms=1500&lead_s=<measured>

    The poll is simultaneously a request, an announcement and a heartbeat. A receiver that stops polling disappears after an 8 s TTL and the relay shuts itself down — which is why an absent mini PC costs the decoder nothing at all: no process is started and no port is held open waiting.

  5. Box relay.py → ffmpeg

    The box starts pushing audio

    Seeing a receiver, the relay reads the currently selected audio PID from the service and spawns an ffmpeg process outside Enigma2 that copies that one elementary stream, uncompressed-untouched, into MPEG-TS over a TCP connection out to 10.77.0.2:9010. About 26 kB/s. Nothing is re-encoded and nothing about this touches the user interface thread.

  6. PC relay_source.py → ffmpeg

    Audio becomes chunks

    The receiver probes the first packets of each epoch for their timestamp and discards the audio ahead of the intended start — -c copy can only seek to a container boundary, so the stream always begins about 0.65 s early, and that excess is dropped rather than corrected through a feedback loop. What remains is decoded from AAC and resampled to 16 kHz mono, then cut into 160 ms chunks and queued (40 deep).

  7. PC realtime_server.py → asr_capi.py

    The recogniser runs

    Each chunk is pushed into the streaming recogniser through a ctypes binding to NeMo-Speech.cpp. The model is cache-aware, so state carries between chunks; rnnt_right_context: 1 means it looks one block ahead before committing to a token. Compute is about 74 ms per 160 ms chunk at p50 — roughly half of real time, which is the whole reason a fanless N95 is enough.

  8. PC stabilizer.py → led_subtitles/

    Words are stabilised and laid out

    The raw hypothesis is unstable by nature: a streaming model rewrites its own tail. The stabiliser holds a short history and only promotes a word once several passes agree on it. The LED formatter then packs the confirmed words into two rolling lines of at most 40 characters, with the last word held back briefly so a word still being revised does not flicker in front of the reader.

  9. PC subtitle_sinks.py + enigma2_protocol.py

    Two lines go back over the WebSocket

    A subtitle_state message carries the two display lines, a monotonic sequence number and the epoch — and nothing else. The validator actively rejects a cumulative transcript field, because sending the growing transcript would make traffic and memory quadratic in session length. The producer never waits for an acknowledgement.

  10. Box wsserver.py → subtitle_session.py → plugin.py

    The panel is painted

    A hand-written WebSocket server — the box has no WebSocket library, and installing one into set-top flash to receive two lines of text would be out of proportion — hands the frame to the session, which keeps exactly one pending state and discards anything from an old epoch or an older sequence number. The render loop paints it at up to 25 fps onto a black band at the bottom of the screen.

  11. PC hearable_idle_watchdog.py

    And afterwards, it all goes away

    When the television stops sending audio, the watchdog starts a timer and powers the mini PC off. Pressing F4 again paints a four-second farewell carrying the version number; switching the receiver off entirely sends a farewell datagram so the mini PC does not even wait out its grace period.

03

The audio path in detail

One PID, off an already-tuned service, compressed all the way to the recogniser's doorstep.

The tap

Enigma2 holds twenty-four PID filters open on demux0 while showing a channel. HearAble opens a twenty-fifth. Userspace only — no kernel module, no patched image, nothing persistent on the box.

open("/dev/dvb/adapter0/demux0", O_RDWR | O_NONBLOCK)
DMX_SET_BUFFER_SIZE   1 MiB
DMX_SET_PES_FILTER    pid      = <selected audio PID>
                      input    = DMX_IN_FRONTEND
                      output   = DMX_OUT_TSDEMUX_TAP
                      pes_type = DMX_PES_OTHER
                      flags    = DMX_IMMEDIATE_START
read()                188-byte MPEG-TS packets, 132.8 kbit/s
Why these constants and not the neighbouring ones
ConstantEffect
DMX_IN_FRONTENDReads the stream entering the receiver rather than what the decoder is presenting. This is what puts the tap 64 ms ahead of the picture instead of behind it, and what keeps HearAble entirely out of the video path.
DMX_OUT_TSDEMUX_TAPDelivers to this file descriptor. First data after 0.03 s. Its neighbour DMX_OUT_TS_TAP opens without error and delivers zero bytes for ever, because it routes to the dvr device instead.
1 MiB bufferEnough that a scheduling hiccup inside Enigma2 does not cost packets. The demux drops silently when its ring fills.

The two channels of hearable-audio-relay/1

Control and media are deliberately separate connections, in opposite directions, so a slow or dead receiver can never stall the user interface.

ControlMedia
Directionreceiver → boxbox → receiver
TransportHTTP GET on :8770/controlTCP, MPEG-TS, to the receiver's port
Who connectsthe receiverthe box
Cadenceevery 500 mscontinuous, at playback rate
Runs inside Enigma2yes — the pluginno — a separate ffmpeg
Failure costreceiver forgotten after 8 srelay exits; control is unaffected

The control response is a stable JSON document: protocol name, source_type (dvb for live television), the service reference, an source_epoch that increments on every discontinuity, the relay state, the selected audio track and PID, and the lead currently in force.

Surviving a dead cable

The receiver gives up on a silent connection after 15 s and sets SO_KEEPALIVE with a 10 s idle, 5 s interval and 3 probes. Without that, a socket timeout swallowed by a bare continue held a dead connection indefinitely — eighteen respawns, three half-open sockets and 80 kB of unread data, a state only a physically unplugged cable produced.

04

Recognition and stabilisation

The model produces an unstable hypothesis many times a second. Turning that into something a person can read is a separate problem from recognising it.

Model
Nemotron 3.5 ASR Streaming 0.6B, q8_0, 741 548 352 bytes, sha256 a5c435f2…f429ae
Runtime
NeMo-Speech.cpp at 4f96762 on ggml, CPU backend, built natively on the machine
Chunk
160 ms · rnnt_right_context: 1 · language it-IT
Compute
74 ms per chunk at p50 — RTF 0.4848 over thirty minutes of live television
Governor
performance, set by its own unit. Worth 27.6 % of the real-time factor on this bursty duty cycle

The stabiliser

A streaming RNN-T revises its own tail constantly: "andiamo a Ro" becomes "andiamo a Roma" becomes "andiamo a Roma sì". Showing that raw would be unreadable. The stabiliser keeps a short history of hypotheses and promotes a word to stable only once several consecutive passes agree on it at the same position. The unconfirmed tail is not displayed at all.

The two-line formatter

Confirmed words are packed into a roll-up display of two rows, at most 40 characters each — the geometry that reads as one block from across a room. Three parameters govern when a word is allowed onto the screen:

config/led_subtitles.json · the commit rules
KeyValueMeaning
COMMIT_STABLE_UPDATES2Passes that must agree before a word is committed
COMMIT_LOOKAHEAD1Words that must follow it before it counts as settled
COMMIT_TAIL_HOLD_WORDS1How many trailing words are withheld from commit
COMMIT_TAIL_FLUSH_MS450How long the held tail waits before being released anyway
SHOW_UNSTABLE_TAILfalseUnconfirmed words are never painted
MAX_CHARS40Characters per line; lines do not wrap

What releases the held word, and what does not

A pause does not. Releasing the tail after COMMIT_TAIL_FLUSH_MS of the hypothesis not changing was built, deployed, and rejected on live television: of the words it released, 55 % and 48 % across two sessions were ones the model then changed. Raising the threshold does not rescue it — at three seconds it is still one word in five, by which point the word is late enough that waiting for the next sentence costs no more.

What does release it is the model's own full stop. A terminal ., ? or ! on the held word is the recogniser claiming the sentence is finished, rather than us observing that it paused: 4.4 % and 0.0 % later changed, on the same two sessions. COMMIT_TAIL_RELEASE chooses between off, time and punctuation; the default is off, and docs/parola_finale.md carries every number behind that choice.

Where the second goes

Measured end to end: p50 1.072 s from audio consumed to subtitle state emitted (n = 1461, p05 0.893 s).

ComponentCostWhat it buys
Chunking, 160 ms~80 ms avgAudio arrives in blocks
Right context, 1 block~160 msThe model sees ahead before committing
ASR compute74 msThe inference itself
Stabiliser confirmations320–480 msWords that do not rewrite themselves
Tail hold≤450 msA last word the model has finished with
Repaint cap, 6 fps≤166 msCorrections that do not strobe
Publication delay0 msNothing: the tap is already ahead
05

Text back to the screen

A small protocol with two rules, a hand-rolled server, and a renderer that can never accumulate a backlog.

hearable-enigma2/1

One JSON object per WebSocket text frame, over the private link. The producer connects out; the renderer listens. No TLS and no authentication — the cable is the trust boundary, and the port must not be exposed beyond it.

MessageDirectionPurpose
hellobothRole, project, protocol version
capabilitiesbothLine count, line length, ACK support
subtitle_stateproducer → rendererThe two display lines, a sequence number, the epoch
clear_subtitlesproducer → rendererBlank the panel now
source_changedproducer → rendererThe channel changed; a new epoch begins
heartbeatproducer → rendererLiveness while nothing is being said
render_ackrenderer → producerThis sequence number reached the screen

Rule one: never send a cumulative transcript. validate() rejects final_text and raw outright. Rule two: the producer never waits. Acknowledgements are observational. There is exactly one pending state and a newer one replaces it, so a renderer that falls behind loses freshness rather than accumulating a queue.

The renderer

The session drops any state whose epoch is older than the current one, or whose sequence number is older than the last rendered — the two mechanisms that make a channel change, a restart or a reconnection safe. A separate rule takes the panel down when the producer goes quiet for 8 s or disconnects entirely, because a test message once sat on a television for hours.

Drawing on the television

The panel is an Enigma2 skin generated at runtime: two centred white lines on a black band at 85 % opacity, each about 7.5 % of screen height, touching, sitting 7 % above the bottom edge.

Colours are #AARRGGBB

And AA is transparency, not opacity. #ffffffff is the skin's own definition of invisible — glyphs written that way are drawn see-through and take the colour of whatever is behind them. White is #00ffffff. The band is #26000000 at 85 %.

The window is the band

A full-screen transparent window looks identical and captures the remote control: volume keeps working because it is handled globally, but MENU stops responding. The screen is positioned and sized to exactly the two lines it draws.

The OSD canvas is 1280 × 720 even on an HD service. Five look parameters are configurable at runtime in /etc/enigma2/hearable_look.json — font_scale, line_gap, background_opacity, background_enabled, padding — and a change destroys and re-instantiates the dialog rather than restarting Enigma2, which would blank the picture.

06

What makes it an appliance

Nobody logs into the mini PC. It arrives when the button is pressed and leaves when the television goes quiet.

systemd units on the mini PC
UnitTypeJob
hearable-governoroneshot, RemainAfterExitEvery core to performance; back to powersave on stop. A Requires= of the pipeline.
hearable-t9simple, Restart=alwaysThe pipeline. KillSignal=SIGINT, 30 s stop timeout, restart limit in [Unit].
hearable-idle-watchdogsimple, rootPowers the machine off. Independent of the recogniser by design.
hearable-rgbsimpleThe status ring. Degrades to nothing if pyserial is absent.

Two systemd details worth copying

StartLimitIntervalSec belongs in [Unit]. Under [Service] it is silently ignored and the restart limit does not exist at all.

Restart=always, never on-failure. A clean exit is still an appliance that has stopped making subtitles — a tool once stopped the pipeline with SIGINT, systemd saw exit code 0 and left it dead for twenty-three minutes.

The private link

An /etc/network/interfaces.d/hearable stanza gives the NIC a static 10.77.0.2/24 with no gateway and no DNS, so it can never become the default route, and re-arms Wake-on-LAN on every boot because the r8169 driver clears it:

auto enp1s0
iface enp1s0 inet static
    address 10.77.0.2
    netmask 255.255.255.0
    up /usr/sbin/ethtool -s $IFACE wol g || true

Nothing on the subtitle path goes through the house network: no router, no DHCP, no name resolution. The house Wi-Fi going down cannot take subtitles with it.

The idle watchdog

It asks two questions and needs both to act: is anything connected to port 9010 (read from /proc/net/tcp), and is the decoder still watching (its /api/powerstate, falling back to a ping)? With no audio session it starts a timer and powers down; a returning session cancels it.

/etc/default/hearable
VariableValueWhy
HEARABLE_IDLE_POWEROFF_MINUTES20The signal is the session's presence, not speech: a channel can be silent or play music for half an hour and still be on.
HEARABLE_IDLE_GRACE_MINUTES3Short wait after the decoder says goodbye. Deliberately not instant: Enigma2 also says goodbye when it restarts for an update.

The status ring

A CH340 serial bridge on the case, named by a udev rule so it does not depend on enumeration order. The protocol is a five-byte frame at 10 000 baud: 0xFA, mode, brightness, speed, checksum — with brightness and speed inverted relative to intuition.

07

Every file involved

What the system is made of, and what it puts on the two machines.

On the receiver — the plugin

Copied to /usr/lib/enigma2/python/Plugins/Extensions/HearAbleOSD/. Six files plus one shared module deployed from the project rather than duplicated, so the two ends of the protocol cannot drift apart.

FileLinesRole
plugin.py1269The plugin proper: skin generation, key handling, lifecycle messages, look configuration, render and poll loops, status file, control endpoint
relay.py812The audio relay: control endpoint state, ffmpeg supervision, epoch management, Wake-on-LAN, the farewell datagram
wsserver.py331A WebSocket server in hashlib, base64 and struct, because the box has no WebSocket library
subtitle_session.py284Receiver-side state: epoch and sequence rejection, one pending state, staleness. Testable without a set-top box
dvb_demux_tap.py159The PID filter itself: DMX_SET_PES_FILTER and the read loop
__init__.py9The plugin's own copy of the version string, checked against the package by a test
enigma2_protocol.py278Deployed from hearable/, not duplicated: the wire format both ends import

On the mini PC — the pipeline

The repository is checked out at /home/hearable/hearable. These are the modules on the live-television path.

FileLinesRole
hearable/realtime_server.py1362The pipeline: chunk loop, ASR events, the LED bridge, tick, metrics, latency records
hearable/audio_sources.py1364Source abstraction and factory; epoch and source-change handling
hearable/subtitle_sinks.py678The WebSocket client to the box, with reconnection and heartbeat
hearable/sf8008/relay_source.py627The receiving end of the audio relay: listen, control poll, timestamp trim, AAC decode, keepalive
hearable/enigma2_protocol.py278Message construction and validation; the single source of truth for the wire
hearable/asr_capi.py243ctypes binding to NeMo-Speech.cpp: push samples, drain events
hearable/stabilizer.py135C_tail1: hypothesis history, common prefix, promotion to stable
hearable/led_subtitles/rollup.py134The two rolling rows and their timing
hearable/led_subtitles/asr_commit.py109Which word may be committed, and why: stability, lookahead, forced flush
hearable/led_subtitles/config.py70The commit and layout parameters
hearable/led_subtitles/layout.py68Tokenisation and line packing at 40 characters
hearable/io_config.py45Loading the source and sink configuration files
hearable/__init__.py15The one place the release version is written

Four further modules under hearable/sf8008/ — supervised_reader.py, box.py, byte_position.py, frame_match.py — implement sf8008_usb, an earlier and superseded source type that required the receiving machine to hold a bit-identical copy of the media, so the decoder's play position could be recovered by matching a frame off its screen. Nothing on the current path uses them.

Local media takes the same path

A film played from the receiver's own storage — a USB stick, an internal drive — is subtitled by the same chain, with no second code path. The relay is a second reader of whatever the decoder is playing: the demux for live television, the file for a file. Either way it forwards only the selected audio track, compressed, from ahead of the viewer's position. The receiver sees source_type: file instead of dvb in the control response and carries on.

File playback · eight checks, all passing
CheckResultRecovery
First subtitle after play startsPass36.1 s
PausePassrelay paused, 0 stale
ResumePass1.2 s
Seek forwardPass19.1 s
Seek backwardPass64.9 s
Change to a different filmPassepoch 41 → 42
F4 off then onPass5.4 s
Audio track changeNot testableone track in the file

FILE_STALE_SUBTITLES: 0 across the run — nothing from before a pause, a seek or a change of file was ever painted afterwards.

Two deliberate differences on a file

The sync keys are refused. F2 and F3 adjust the publication delay, which exists to compensate for how far ahead of the picture the tap runs. A file has no such offset, so the keys do nothing and say so, rather than silently applying a correction with no meaning.

The first subtitle takes longer — 36.1 s against about 4 s on live television, because starting a film means the relay spawning, the stream beginning at a container boundary, and the recogniser having heard nothing yet. Once running, it behaves the same.

Measured with the film on the box's USB storage (/media/hdd). An NVMe or internal drive is the same path as far as HearAble is concerned — it reads whatever Enigma2 is playing and never touches the storage itself — but that has not been measured and is not claimed here.

Configuration and deployment

Source in the repositoryInstalled asWhat it sets
config/audio_source_sf8008_relay_private.jsonread in placeBox at 10.77.0.1, control 8770, receiver 9010, 40-chunk queue, 1500 ms lead
config/subtitle_sink_sf8008_private.jsonread in placeWebSocket to ws://10.77.0.1:8770/hearable, 2 s heartbeat, backoff 250 ms → 5 s
config/led_subtitles.jsonread in placeTwo lines of 40, the commit rules, hold times, the Italian orphan-word list
scripts/hearable_t9_live.shlaunched by the unitGovernor, model path, --chunk-ms 160, --gpu -1, --language it-IT
systemd/hearable-t9.service/etc/systemd/system/The pipeline unit
systemd/hearable-governor.service/etc/systemd/system/The CPU governor unit
systemd/hearable-idle-watchdog.service/etc/systemd/system/The power-off watchdog unit
systemd/hearable-rgb.service/etc/systemd/system/The status ring unit
systemd/hearable.default/etc/default/hearableIdle timeout and grace period
systemd/hearable-private-link.conf/etc/network/interfaces.d/hearableStatic 10.77.0.2/24, no gateway, Wake-on-LAN re-armed at boot
systemd/99-hearable-ring.rules/etc/udev/rules.d/A stable name for the CH340 bridge
tools/hearable_idle_watchdog.pyrun by its unitSession detection and the power-off decision
tools/hearable_rgb.pyrun by its unitThe ring protocol and the state-to-colour map
tools/sf8008_install_plugin.shrun from a workstationCopies the plugin, refuses a version mismatch, verifies a new process is running

Files the system creates on the machines

Nothing else on either machine is modified. Everything below is written by HearAble itself.

PathWhereContents · lifetime
/etc/enigma2/hearable_look.jsonBoxThe five look parameters. Written when the viewer changes one; survives reboots
/etc/enigma2/hearable_sync.jsonBoxPublication delays, step and limits. Written 2 s after the viewer stops pressing, to avoid a flash write storm
/etc/enigma2/hearable_wol.jsonBoxThe mini PC's MAC, broadcast address and ports
/tmp/hearable_osd_status.jsonBoxFull state, rewritten every second. The instrument every gate reads
/tmp/hearable_osd.logBoxPlugin log. Cleared at boot
/tmp/hearable_diag.jsonlBoxDiagnostic records, capped at 20 000. Off by default
/run/hearable-idle.jsonPCThe watchdog's view of the session and its timer
benchmark/results/T9_N95_cpu/*.jsonPCMetrics and event logs per run, named by LABEL

The receiver's own configuration is never edited. No Enigma2 setting is changed, no init script is added, no kernel module is loaded, and the tuner is never opened by HearAble. Removing the plugin directory and the three hearable_*.json files returns the box to stock.