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.
The shape
Two machines. Compressed audio goes one way, two lines of text come back. Nothing else crosses the wire.
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.
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
-
Box
plugin.py·WHERE_SESSIONSTARTEnigma2 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.
-
Box
plugin.py→relay.pyF4is pressedKey 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.255on UDP ports 9 and 7, repeating up to fifteen times at twenty-second intervals. -
PC
hearable-t9.serviceThe mini PC wakes and starts the pipeline
The NIC wakes the machine in about 32 seconds. systemd brings up
hearable-governorfirst — it is a hard dependency, not a nicety — then launchesscripts/hearable_t9_live.sh, which execspython3 -m hearable.realtime_serverwith the private-link source and sink configurations. -
PC
sf8008/relay_source.pyThe 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.
-
Box
relay.py→ffmpegThe box starts pushing audio
Seeing a receiver, the relay reads the currently selected audio PID from the service and spawns an
ffmpegprocess outside Enigma2 that copies that one elementary stream, uncompressed-untouched, into MPEG-TS over a TCP connection out to10.77.0.2:9010. About 26 kB/s. Nothing is re-encoded and nothing about this touches the user interface thread. -
PC
relay_source.py→ffmpegAudio becomes chunks
The receiver probes the first packets of each epoch for their timestamp and discards the audio ahead of the intended start —
-c copycan 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). -
PC
realtime_server.py→asr_capi.pyThe 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: 1means 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. -
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.
-
PC
subtitle_sinks.py+enigma2_protocol.pyTwo lines go back over the WebSocket
A
subtitle_statemessage 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. -
Box
wsserver.py→subtitle_session.py→plugin.pyThe 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.
-
PC
hearable_idle_watchdog.pyAnd afterwards, it all goes away
When the television stops sending audio, the watchdog starts a timer and powers the mini PC off. Pressing
F4again 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.
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
| Constant | Effect |
|---|---|
DMX_IN_FRONTEND | Reads 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_TAP | Delivers 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 buffer | Enough 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.
| Control | Media | |
|---|---|---|
| Direction | receiver → box | box → receiver |
| Transport | HTTP GET on :8770/control | TCP, MPEG-TS, to the receiver's port |
| Who connects | the receiver | the box |
| Cadence | every 500 ms | continuous, at playback rate |
| Runs inside Enigma2 | yes — the plugin | no — a separate ffmpeg |
| Failure cost | receiver forgotten after 8 s | relay 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.
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, sha256a5c435f2…f429ae - Runtime
- NeMo-Speech.cpp at
4f96762on ggml, CPU backend, built natively on the machine - Chunk
- 160 ms ·
rnnt_right_context: 1· languageit-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:
| Key | Value | Meaning |
|---|---|---|
COMMIT_STABLE_UPDATES | 2 | Passes that must agree before a word is committed |
COMMIT_LOOKAHEAD | 1 | Words that must follow it before it counts as settled |
COMMIT_TAIL_HOLD_WORDS | 1 | How many trailing words are withheld from commit |
COMMIT_TAIL_FLUSH_MS | 450 | How long the held tail waits before being released anyway |
SHOW_UNSTABLE_TAIL | false | Unconfirmed words are never painted |
MAX_CHARS | 40 | Characters 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).
| Component | Cost | What it buys |
|---|---|---|
| Chunking, 160 ms | ~80 ms avg | Audio arrives in blocks |
| Right context, 1 block | ~160 ms | The model sees ahead before committing |
| ASR compute | 74 ms | The inference itself |
| Stabiliser confirmations | 320–480 ms | Words that do not rewrite themselves |
| Tail hold | ≤450 ms | A last word the model has finished with |
| Repaint cap, 6 fps | ≤166 ms | Corrections that do not strobe |
| Publication delay | 0 ms | Nothing: the tap is already ahead |
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.
| Message | Direction | Purpose |
|---|---|---|
hello | both | Role, project, protocol version |
capabilities | both | Line count, line length, ACK support |
subtitle_state | producer → renderer | The two display lines, a sequence number, the epoch |
clear_subtitles | producer → renderer | Blank the panel now |
source_changed | producer → renderer | The channel changed; a new epoch begins |
heartbeat | producer → renderer | Liveness while nothing is being said |
render_ack | renderer → producer | This 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.
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.
| Unit | Type | Job |
|---|---|---|
hearable-governor | oneshot, RemainAfterExit | Every core to performance; back to powersave on stop. A Requires= of the pipeline. |
hearable-t9 | simple, Restart=always | The pipeline. KillSignal=SIGINT, 30 s stop timeout, restart limit in [Unit]. |
hearable-idle-watchdog | simple, root | Powers the machine off. Independent of the recogniser by design. |
hearable-rgb | simple | The 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.
| Variable | Value | Why |
|---|---|---|
HEARABLE_IDLE_POWEROFF_MINUTES | 20 | The 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_MINUTES | 3 | Short 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.
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.
| File | Lines | Role |
|---|---|---|
plugin.py | 1269 | The plugin proper: skin generation, key handling, lifecycle messages, look configuration, render and poll loops, status file, control endpoint |
relay.py | 812 | The audio relay: control endpoint state, ffmpeg supervision, epoch management, Wake-on-LAN, the farewell datagram |
wsserver.py | 331 | A WebSocket server in hashlib, base64 and struct, because the box has no WebSocket library |
subtitle_session.py | 284 | Receiver-side state: epoch and sequence rejection, one pending state, staleness. Testable without a set-top box |
dvb_demux_tap.py | 159 | The PID filter itself: DMX_SET_PES_FILTER and the read loop |
__init__.py | 9 | The plugin's own copy of the version string, checked against the package by a test |
enigma2_protocol.py | 278 | Deployed 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.
| File | Lines | Role |
|---|---|---|
hearable/realtime_server.py | 1362 | The pipeline: chunk loop, ASR events, the LED bridge, tick, metrics, latency records |
hearable/audio_sources.py | 1364 | Source abstraction and factory; epoch and source-change handling |
hearable/subtitle_sinks.py | 678 | The WebSocket client to the box, with reconnection and heartbeat |
hearable/sf8008/relay_source.py | 627 | The receiving end of the audio relay: listen, control poll, timestamp trim, AAC decode, keepalive |
hearable/enigma2_protocol.py | 278 | Message construction and validation; the single source of truth for the wire |
hearable/asr_capi.py | 243 | ctypes binding to NeMo-Speech.cpp: push samples, drain events |
hearable/stabilizer.py | 135 | C_tail1: hypothesis history, common prefix, promotion to stable |
hearable/led_subtitles/rollup.py | 134 | The two rolling rows and their timing |
hearable/led_subtitles/asr_commit.py | 109 | Which word may be committed, and why: stability, lookahead, forced flush |
hearable/led_subtitles/config.py | 70 | The commit and layout parameters |
hearable/led_subtitles/layout.py | 68 | Tokenisation and line packing at 40 characters |
hearable/io_config.py | 45 | Loading the source and sink configuration files |
hearable/__init__.py | 15 | The 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.
| Check | Result | Recovery |
|---|---|---|
| First subtitle after play starts | Pass | 36.1 s |
| Pause | Pass | relay paused, 0 stale |
| Resume | Pass | 1.2 s |
| Seek forward | Pass | 19.1 s |
| Seek backward | Pass | 64.9 s |
| Change to a different film | Pass | epoch 41 → 42 |
F4 off then on | Pass | 5.4 s |
| Audio track change | Not testable | one 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 repository | Installed as | What it sets |
|---|---|---|
config/audio_source_sf8008_relay_private.json | read in place | Box at 10.77.0.1, control 8770, receiver 9010, 40-chunk queue, 1500 ms lead |
config/subtitle_sink_sf8008_private.json | read in place | WebSocket to ws://10.77.0.1:8770/hearable, 2 s heartbeat, backoff 250 ms → 5 s |
config/led_subtitles.json | read in place | Two lines of 40, the commit rules, hold times, the Italian orphan-word list |
scripts/hearable_t9_live.sh | launched by the unit | Governor, 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/hearable | Idle timeout and grace period |
systemd/hearable-private-link.conf | /etc/network/interfaces.d/hearable | Static 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.py | run by its unit | Session detection and the power-off decision |
tools/hearable_rgb.py | run by its unit | The ring protocol and the state-to-colour map |
tools/sf8008_install_plugin.sh | run from a workstation | Copies 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.
| Path | Where | Contents · lifetime |
|---|---|---|
/etc/enigma2/hearable_look.json | Box | The five look parameters. Written when the viewer changes one; survives reboots |
/etc/enigma2/hearable_sync.json | Box | Publication delays, step and limits. Written 2 s after the viewer stops pressing, to avoid a flash write storm |
/etc/enigma2/hearable_wol.json | Box | The mini PC's MAC, broadcast address and ports |
/tmp/hearable_osd_status.json | Box | Full state, rewritten every second. The instrument every gate reads |
/tmp/hearable_osd.log | Box | Plugin log. Cleared at boot |
/tmp/hearable_diag.jsonl | Box | Diagnostic records, capped at 20 000. Off by default |
/run/hearable-idle.json | PC | The watchdog's view of the session and its timer |
benchmark/results/T9_N95_cpu/*.json | PC | Metrics 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.