Updated: now running on go-livepeer v0.9.1 + livepeer-gateway v1.0.0. Three of the problems in my original write-up no longer exist — details below.
I’ve been building a live-runner capability and it’s working end to end on mainnet, paid. Sharing the code and, more usefully, the write-up of everything between an off-chain demo and a redeemed winning ticket.
What it does
Takes one live A/V stream and returns transcoded renditions and live captions from the same session. One upload, decoded once, feeding both the encoder ladder and speech-to-text. Both halves are configurable and either can be switched off — "captions": false serves a plain transcoding customer at plain transcoding cost, and "renditions": [] gives you captions alone.
The framing I find interesting: this is an add-on to transcoding an orchestrator already does, not a new service anyone has to believe in. Same input, captions as the differentiator.
Captions arrive as interim previews from a small model, superseded by finals from a larger one, and share the input stream’s PTS timeline — so a client holding playback slightly behind capture can put a subtitle on exactly the frames it describes.
Where it runs
A Radxa Rock 5B (RK3588, 8W, ARM64) at home, on a domestic connection. Four renditions plus captions, zero dropped frames. Some numbers, since I couldn’t find ARM figures anywhere:
• whisper base int8: 5.14x realtime — and faster on 2 threads than 4, it’s memory-bandwidth bound
• whisper tiny: 11.97x
• both x264 encodes fit on the little A55 cores at ~3x realtime, so the missing Rockchip hardware encoder didn’t matter
It’s actually paid
Not a testnet demo. Live sessions paid in PM tickets, winning tickets redeemed on Arbitrum, fees credited to the orchestrator’s pendingFees. A 60s session produced 8 winning tickets, redeemed for a few pence of gas.
The orchestrator’s key never touches the box: the node runs on a disposable key with -ethOrchAddr pointing at the cold address. requireValidWinningTicket has no msg.sender check, so redemption pays _ticket.recipient regardless of who submits it — a compromised node key can only waste its own gas.
What v0.9.1 + SDK v1.0.0 fixed
My original post documented five walls. Three are now gone, and I’ve deleted the workarounds rather than shipping them:
• registration price shape — register_runner now takes price/currency/unit directly
• billing unit (lv2v vs live, which produced numTickets 6088030703 exceeds maximum of 100) — the SDK derives the payment type from price_info.unit
• sessions dying at ~15s — reserve_session answers the 402 challenge and then keeps the session funded in the background, which is what metered pricing needs
I verified a 60s paid session on the new stack with no client-side payment code at all. Thanks to @j0sh and @rickstaa — #4008 (session-scoped payment URL) was the piece that closed it, and it landed the same day I was working around its absence.
What’s still worth knowing
• More renditions do not earn more. Revenue is seconds × price_per_second. A bigger frame or a longer ladder only costs you CPU. This is the most common wrong assumption about this rail.
• numTickets must stay under 100 — hardcoded in server/remote_signer.go — and the initial reservation uses a 10s preload, so (10 × price_per_second) / ticketEV ≤ 100. At the default ticketEV that caps price around $1.36/hour. Raise ticketEV to raise the ceiling.
• Expected wins = spend ÷ faceValue. ticketEV cancels out; it only sets how many tickets carry that spend.
• The fee recipient must be an active transcoder — updateTranscoderWithFees requires it, so a fresh orchestrator identity must be bonded into the active set before it can collect anything.
• Winning tickets are redeemed late by design — held until their params expire so the sender can’t reuse them. Before debugging a “stuck” ticket, compare paramsExpirationBlock in the ticketQueue table against RoundsManager.blockNum(). Mine were simply 23 L1 blocks early.
• -ethPassword is not a path, despite the help text. Give it a filename and the filename becomes the password.
Caveats, honestly
• The BYOC rail is written but never exercised. Registration works (/capability/register, Authorization: <orchSecret>) and the streaming routes are live under /ai/stream/…, but no SDK — mine or upstream — has a client for BYOC streaming, so driving it means hand-rolling a signed job. Unproven.
• Known upstream issue where an orchestrator can drop a stream (#4026, #4004). The capability re-subscribes rather than ending the session, but clients should expect a session to end unexpectedly.
• Capacity 1 on an 8W SBC. This is a proof, not a service to depend on.
How to get it
On Radicle rather than GitHub — peer-to-peer git, no forge in the middle:
curl -sSf https://radicle.xyz/install | sh
rad clone rad:zVjqknCK5c7TWDqeVaTuAKWmHgxZ
A normal git repo with normal history. Public domain (Unlicense). deploy/PAYMENTS.md is the part most likely to save someone a day.
What would help
1. Let’s agree on a capability name — and I’d like to convene that rather than just claim one. Discovery matches capability strings exactly: no prefixes, no wildcards. So transcoding-with-captions/whisper — a name I invented — is only reachable by a client that already knows that exact string. If two of us ship near-identical capabilities under different names we fragment supply for nothing. Worth settling: does the model belong in the name or in metadata? Is pipeline/model right for add-ons to transcoding rather than standalone pipelines? Does anyone keep a list of known names? I’m not attached to mine.
2. A GPU orchestrator willing to run it for an afternoon. Everything above is CPU-only on ARM. The GPU container target is written from the CUDA docs and has never been run. It needs nothing exotic — whisper small int8 wants ~1–2GB VRAM, so anything from a GTX 1660 up. An hour of someone’s time and there’d be a verified GPU path.
3. Someone to own the client side. The capability is done; what’s missing is a gateway that requests it and a player that renders the captions. Captions are plain SSE with start/end on the input PTS timeline — see the client contract in deploy/README.md.
In return, deploy/PAYMENTS.md documents a working configuration with the source line explaining each constraint. Happy for any of it to land in the official docs.
And the next thing I want to build is live-translated subtitles — “Marie avait un petit agneau” — on the same seam and the same session. The caption backend is already pluggable, so it’s an extension rather than a new bet.