Skip to content

Commit 99d2efa

Browse files
rickstaaclaude
andcommitted
refactor(realtime-transcription): match the other examples, fix three bugs
Review pass over PR #57 before merge. Consistency with the merged examples: - port 5005 to 8989, and CMD to ENTRYPOINT so compose stops repeating `python runner.py` - flatten pyproject; the `runner` extra was dead config and broke `uv run runner.py` - drop --device/--compute-type, whose cpu/int8 defaults contradicted the GPU-only docs - cut the compose and Dockerfile headers to the echo template - client takes its input positionally and inlines the signer, like echo - hold the session with `async with`, like echo - shorten the runner docstring and constant comments to the house shape Fixes: - eos cancels the worker before its closing transcribe, so a partial can no longer land after the final - _rms no longer raises when a frame splits a sample - the client only builds an SSL context for wss:// New: - start/end seconds on every message, matching what Deepgram and OpenAI segments carry - `-` reads raw PCM from stdin, so a microphone works through ffmpeg - sample.wav is fetched from a public domain NASA clip instead of committed Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 95ac834 commit 99d2efa

8 files changed

Lines changed: 163 additions & 122 deletions

File tree

realtime-transcription/.env.example

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,6 @@
11
# Copy to .env (gitignored) and fill in. Never commit secrets.
22
# Keystore dirs: absolute paths OUTSIDE this repo, mounted read-only.
33

4-
# Device only: the model is fixed at large-v3-turbo, which needs a GPU to stay
5-
# realtime. cpu/int8 loads but falls behind a live stream.
6-
WHISPER_DEVICE=cuda
7-
WHISPER_COMPUTE=float16
8-
9-
# --- On-chain (paid) only below; offchain ignores these. ---
10-
114
NETWORK=arbitrum-one-mainnet
125
ETH_RPC_URL=https://arb1.arbitrum.io/rpc
136

realtime-transcription/Dockerfile

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
1-
# Realtime-transcription app: an aiohttp WebSocket server wrapping faster-whisper
2-
# that self-registers as a Live Runner. GPU only: large-v3-turbo is the model that
3-
# is both accurate and fast enough to keep pace with a live stream, and it needs a
4-
# CUDA runtime (CTranslate2 wants cuBLAS + cuDNN).
1+
# Realtime-transcription example app (persistent WebSocket, GPU speech-to-text).
52
FROM python:3.12-slim
63

74
# Flush stdout/stderr immediately so output isn't block-buffered in `docker logs`.
@@ -11,9 +8,9 @@ RUN apt-get update \
118
&& apt-get install -y --no-install-recommends git \
129
&& rm -rf /var/lib/apt/lists/*
1310

14-
# CTranslate2 loads cuBLAS/cuDNN from the nvidia pip wheels, so no CUDA base image
11+
# livepeer-gateway SDK isn't on PyPI yet; install from Git. CTranslate2 (under
12+
# faster-whisper) loads cuBLAS/cuDNN from the nvidia wheels, so no CUDA base image
1513
# is needed -- the host driver comes in via the compose `deploy` reservation.
16-
# livepeer-gateway SDK isn't on PyPI yet; install from Git.
1714
RUN pip install --no-cache-dir \
1815
faster-whisper numpy \
1916
nvidia-cublas-cu12 nvidia-cudnn-cu12 \
@@ -24,6 +21,6 @@ ENV LD_LIBRARY_PATH=/usr/local/lib/python3.12/site-packages/nvidia/cublas/lib:/u
2421
WORKDIR /app
2522
COPY runner.py client.py ./
2623

27-
EXPOSE 5005
24+
EXPOSE 8989
2825

29-
CMD ["python", "runner.py"]
26+
ENTRYPOINT ["python", "runner.py"]

realtime-transcription/README.md

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ Realtime speech-to-text on the Livepeer network over a **WebSocket** — the cli
99
| Registration | dynamic (self-registers via the SDK) |
1010
| Model | `large-v3-turbo` (faster-whisper, fixed) |
1111
| Transport | WebSocket (`/transcribe`) |
12-
| Port | 5005 |
12+
| Port | 8989 |
1313

14-
**Requires an NVIDIA GPU.** The model is fixed at `large-v3-turbo`: it swaps large-v3's 32-layer decoder for 4, so it runs far below realtime on a 3090 while staying near large-v3 quality. On CPU it loads but falls behind a live stream, which is the one thing this example is about. Prerequisites (Docker, `uv`, the not-yet-released SDK) and the shared on-chain/payment setup are in the [repo README](../README.md).
14+
**Requires an NVIDIA GPU.** The model is fixed at `large-v3-turbo`: it swaps large-v3's 32-layer decoder for 4, so it runs far below realtime on a 3090 while staying near large-v3 quality. The device is pinned with it (`cuda`/`float16`) rather than exposed as a flag: on CPU the model loads but falls behind a live stream, which is the one thing this example is about. Prerequisites (Docker, `uv`, the not-yet-released SDK) and the shared on-chain/payment setup are in the [repo README](../README.md).
1515

1616
## How it's wired
1717

@@ -27,25 +27,43 @@ Wire protocol on `/transcribe`:
2727

2828
- client → server: binary frames of **16 kHz mono PCM (int16)**
2929
- client → server: text `eos` to finish
30-
- server → client: JSON `{"text": "...", "final": false|true}`
30+
- server → client: JSON `{"text": "...", "final": false|true, "start": <sec>, "end": <sec>}`
31+
32+
Cumulative, not incremental: each partial carries the whole utterance so far and may revise earlier words, so a client replaces rather than appends. That matches Deepgram and Vosk, and it is the honest shape for a decoder that re-runs over the buffer. Delta protocols (OpenAI's `transcript.text.delta`) only become correct once decoding is append-only, which is what the LocalAgreement approach below buys you.
3133

3234
## Audio
3335

34-
Input must be **16 kHz mono WAV**. Convert any file you have, or record a few seconds of yourself talking:
36+
Input must be **16 kHz mono WAV**. Fetch 21s of NASA podcast speech, public domain under 17 U.S.C. 105:
37+
38+
```sh
39+
curl -sL https://images-assets.nasa.gov/audio/Ep401_Artemis_II_Launch/Ep401_Artemis_II_Launch~128k.mp3 \
40+
| ffmpeg -ss 900 -t 21 -i pipe:0 -ar 16000 -ac 1 sample.wav
41+
```
42+
43+
Or bring your own, replacing `input.mp3` / picking your capture device:
3544

3645
```sh
3746
ffmpeg -i input.mp3 -ar 16000 -ac 1 sample.wav # convert
3847
ffmpeg -f alsa -i default -ar 16000 -ac 1 -t 20 sample.wav # record (macOS: -f avfoundation -i :0)
3948
```
4049

41-
Use a clip with a couple of sentences and a pause between them: the app finalizes on trailing silence, so that is what shows partials turning into finals more than once.
50+
Use a clip with a couple of sentences and a pause between them: the app finalizes on trailing silence, so that is what shows partials turning into finals more than once. The NASA clip is trimmed to three such sentences.
51+
52+
Or skip the file and talk into a microphone: pass `-` and pipe raw PCM in, which streams until you Ctrl-C.
53+
54+
```sh
55+
ffmpeg -f alsa -i default -ar 16000 -ac 1 -f s16le - \
56+
| uv run client.py --discovery https://localhost:8935/discovery -
57+
```
58+
59+
(macOS: `-f avfoundation -i :0`. If `default` fails, name the device: `arecord -l` then `-i plughw:1,0`.)
4260

4361
## Run offchain (free)
4462

4563
```sh
4664
docker compose up -d --build # first run downloads the whisper model
4765
curl -sk https://localhost:8935/discovery | jq '.[].runners[].app' # confirm livepeer-example/realtime-transcription registered
48-
uv run client.py --discovery https://localhost:8935/discovery --file sample.wav
66+
uv run client.py --discovery https://localhost:8935/discovery sample.wav
4967
docker compose down
5068
```
5169

@@ -59,7 +77,7 @@ Layer `compose.onchain.yml` to add a remote signer and run the orchestrator on-c
5977
cp .env.example .env # fill in RPC, network, keystore paths, accounts, pricing
6078
docker compose -f compose.yml -f compose.onchain.yml up -d --build
6179
uv run client.py --discovery https://localhost:8935/discovery \
62-
--signer http://localhost:7936 --file sample.wav
80+
--signer http://localhost:7936 sample.wav
6381
docker compose -f compose.yml -f compose.onchain.yml down
6482
```
6583

@@ -72,5 +90,5 @@ Start an orchestrator built from go-livepeer `v0.9.0` or newer (see [Build from
7290
```sh
7391
./livepeer -orchestrator -useLiveRunners -serviceAddr localhost:8935 -orchSecret abcdef -v 6
7492
uv run runner.py --orchestrator https://localhost:8935 --orchSecret abcdef
75-
uv run client.py --file sample.wav
93+
uv run client.py sample.wav
7694
```

realtime-transcription/client.py

Lines changed: 54 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,21 @@
1010
2. ws_connect() — open the proxied WebSocket to the session URL
1111
3. stop_runner_session() — end the session (settles payment on-chain)
1212
13-
Audio must be 16 kHz mono. Convert anything with ffmpeg:
13+
Audio must be 16 kHz mono. Convert a file, or stream a mic through stdin:
1414
ffmpeg -i input.mp3 -ar 16000 -ac 1 sample.wav
15+
ffmpeg -f alsa -i default -ar 16000 -ac 1 -f s16le - | uv run client.py -
1516
"""
17+
1618
from __future__ import annotations
1719

1820
import argparse
1921
import asyncio
2022
import logging
2123
import ssl
24+
import sys
2225
import wave
2326
from contextlib import suppress
27+
from pathlib import Path
2428

2529
import aiohttp
2630

@@ -40,15 +44,23 @@ def _parse_args() -> argparse.Namespace:
4044
parser = argparse.ArgumentParser(
4145
description="Stream audio to a Whisper Live Runner over WebSocket."
4246
)
47+
parser.add_argument(
48+
"input",
49+
help=(
50+
"16 kHz mono WAV to stream, or - to read raw PCM from stdin "
51+
"(e.g. a mic piped from ffmpeg)"
52+
),
53+
)
4354
parser.add_argument("--discovery", default=DEFAULT_DISCOVERY)
44-
parser.add_argument("--file", required=True, help="16 kHz mono WAV to stream.")
4555
parser.add_argument(
4656
"--signer", default="", help="Remote signer base URL (on-chain/paid path)."
4757
)
4858
return parser.parse_args()
4959

5060

5161
def _read_pcm(path: str) -> bytes:
62+
# The socket carries bare samples with no format header, so the file has to
63+
# already match what the runner expects; readframes() drops the WAV header.
5264
with wave.open(path, "rb") as w:
5365
if (
5466
w.getframerate() != SAMPLE_RATE
@@ -62,11 +74,20 @@ def _read_pcm(path: str) -> bytes:
6274
return w.readframes(w.getnframes())
6375

6476

65-
async def _send(ws: aiohttp.ClientWebSocketResponse, pcm: bytes) -> None:
77+
async def _send(
78+
ws: aiohttp.ClientWebSocketResponse, pcm: bytes, *, live: bool = False
79+
) -> None:
6680
step = SAMPLE_RATE * 2 * CHUNK_MS // 1000 # bytes per chunk
67-
for i in range(0, len(pcm), step):
68-
await ws.send_bytes(pcm[i : i + step])
69-
await asyncio.sleep(CHUNK_MS / 1000) # pace at real time
81+
if live:
82+
# A mic arrives at real time already, so read blocking (off the event
83+
# loop) and forward as it comes; the pipe closing ends the stream.
84+
loop = asyncio.get_running_loop()
85+
while chunk := await loop.run_in_executor(None, sys.stdin.buffer.read, step):
86+
await ws.send_bytes(chunk)
87+
else:
88+
for i in range(0, len(pcm), step):
89+
await ws.send_bytes(pcm[i : i + step])
90+
await asyncio.sleep(CHUNK_MS / 1000) # pace at real time
7091
await ws.send_str("eos")
7192

7293

@@ -78,22 +99,30 @@ async def _recv(ws: aiohttp.ClientWebSocketResponse) -> None:
7899
break
79100
data = msg.json()
80101
marker = "FINAL" if data.get("final") else "partial"
81-
print(f"[{marker}] {data.get('text', '')}")
102+
span = f"{data.get('start', 0):6.2f}-{data.get('end', 0):6.2f}s"
103+
print(f"[{span}] [{marker}] {data.get('text', '')}")
82104

83105

84106
async def main() -> None:
85107
logging.basicConfig(
86108
level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s"
87109
)
88110
args = _parse_args()
89-
pcm = _read_pcm(args.file)
90-
signer_url = args.signer.strip() or None
111+
input_source = args.input.strip()
112+
live = input_source == "-"
113+
if not live:
114+
input_path = Path(input_source).expanduser()
115+
if not input_path.exists():
116+
raise SystemExit(f"input file does not exist: {input_path}")
117+
input_source = str(input_path)
118+
# Read (and validate) the file before reserving a paid session.
119+
pcm = b"" if live else _read_pcm(input_source)
91120
session = None
92121
try:
93122
session = await reserve_session( # Livepeer: 1
94123
discovery_url=args.discovery, # omit if the signer does discovery itself
95124
app=APP_ID,
96-
signer_url=signer_url,
125+
signer_url=args.signer.strip() or None,
97126
)
98127
log.info("session_id=%s app_url=%s", session.session_id, session.app_url)
99128
ws_url = (
@@ -102,14 +131,21 @@ async def main() -> None:
102131
.rstrip("/")
103132
+ "/transcribe"
104133
)
105-
ctx = ssl.create_default_context() # orchestrator serves a self-signed cert
106-
ctx.check_hostname = False
107-
ctx.verify_mode = ssl.CERT_NONE
108-
async with aiohttp.ClientSession() as cs:
109-
async with cs.ws_connect(
110-
ws_url, ssl=ctx, heartbeat=20
111-
) as ws: # Livepeer: 2
112-
await asyncio.gather(_send(ws, pcm), _recv(ws))
134+
# Verification comes off: the orchestrator serves a self-signed cert.
135+
ctx: ssl.SSLContext | None = None
136+
if ws_url.startswith("wss://"):
137+
ctx = ssl.create_default_context()
138+
ctx.check_hostname = False
139+
ctx.verify_mode = ssl.CERT_NONE
140+
141+
# The session funds itself while it is held; leaving this block stops that.
142+
# Here that block is the socket's lifetime, which is what gets metered.
143+
async with session:
144+
async with aiohttp.ClientSession() as cs:
145+
async with cs.ws_connect(
146+
ws_url, ssl=ctx or True, heartbeat=20
147+
) as ws: # Livepeer: 2
148+
await asyncio.gather(_send(ws, pcm, live=live), _recv(ws))
113149
except LivepeerGatewayError as exc:
114150
raise SystemExit(f"ERROR: {exc}") from exc
115151
finally:
Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1-
# On-chain payment overlay for the streaming-ASR example. Layer on the base:
1+
# On-chain payment overlay for realtime-transcription. Layer it on the offchain base:
22
# docker compose -f compose.yml -f compose.onchain.yml up -d --build
33
#
4-
# Adds the shared remote signer, re-points the orchestrator on-chain, and
5-
# registers the app with a price. A WebSocket rides a reserved (persistent,
6-
# metered) session: reserve_session pays at reserve, the meter runs while the
7-
# socket is open. Requires a local .env (gitignored). Then pay through the signer:
4+
# Adds the shared remote signer, re-points the orchestrator on-chain (see
5+
# ../compose.onchain.yml), and registers the app with a price so the orchestrator
6+
# issues a payment challenge. Requires a local .env (gitignored); copy .env.example
7+
# and fill it in. The session is metered, so the socket is billed for as long as it
8+
# stays open. Then pay through the signer:
89
# uv run client.py --discovery https://localhost:8935/discovery \
9-
# --signer http://localhost:7936 --file sample.wav
10+
# --signer http://localhost:7936 sample.wav
1011

1112
services:
1213
signer:
@@ -24,12 +25,9 @@ services:
2425
# Re-declare the command to advertise a price (base file registers free).
2526
app:
2627
command:
27-
- python
28-
- runner.py
2928
- --host=0.0.0.0
3029
- --orchestrator=https://orchestrator:8935
3130
- --orchSecret=abcdef
32-
- --runner-url=http://app:5005
33-
- --device=${WHISPER_DEVICE:-cuda}
34-
- --compute-type=${WHISPER_COMPUTE:-float16}
31+
- --runner-url=http://app:8989
32+
# Billed per second of session (metered); price cap in .env.example.
3533
- --price=${PRICE}

realtime-transcription/compose.yml

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
1-
# Offchain demo: orchestrator + a streaming-ASR app that self-registers
2-
# (dynamic) and serves speech-to-text over a WebSocket.
1+
# End-to-end offchain demo: orchestrator + realtime-transcription app (WebSocket
2+
# speech-to-text).
33
#
4+
# The orchestrator service is defined once in ../compose.orchestrator.yml and
5+
# pulled in with `extends`; this file only adds the app. Needs an NVIDIA GPU: the
6+
# pinned model only keeps pace on one. Once up, stream a WAV from the host:
47
# docker compose up -d --build
5-
# uv run client.py --discovery https://localhost:8935/discovery --file sample.wav
6-
#
7-
# The orchestrator is defined once in ../compose.orchestrator.yml and pulled in
8-
# with `extends`. The app embeds the SDK and registers itself, like hello-world.
9-
# Requires an NVIDIA GPU: the pinned model only keeps pace on one.
8+
# uv run client.py --discovery https://localhost:8935/discovery sample.wav
109

1110
services:
1211
orchestrator:
@@ -22,15 +21,10 @@ services:
2221
orchestrator:
2322
condition: service_healthy
2423
command:
25-
- python
26-
- runner.py
2724
- --host=0.0.0.0
2825
- --orchestrator=https://orchestrator:8935
2926
- --orchSecret=abcdef
30-
- --runner-url=http://app:5005
31-
- --device=${WHISPER_DEVICE:-cuda}
32-
- --compute-type=${WHISPER_COMPUTE:-float16}
33-
# large-v3-turbo needs the GPU to stay ahead of a live stream.
27+
- --runner-url=http://app:8989
3428
deploy:
3529
resources:
3630
reservations:

realtime-transcription/pyproject.toml

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,11 @@ name = "livepeer-realtime-transcription"
33
version = "0.1.0"
44
description = "Streaming speech-to-text (WebSocket) example app for the Livepeer network."
55
requires-python = ">=3.12"
6-
# Base deps are what the *client* needs (host `uv run client.py`). The runner's
7-
# ASR stack (faster-whisper) is the `runner` extra, installed in the Docker image.
86
dependencies = [
9-
"aiohttp", # client WebSocket
10-
"livepeer-gateway",
11-
]
12-
13-
[project.optional-dependencies]
14-
runner = [
157
"faster-whisper", # runner: streaming Whisper ASR
16-
"numpy",
8+
"numpy", # runner: PCM buffers + energy VAD
9+
"aiohttp",
10+
"livepeer-gateway",
1711
]
1812

1913
# livepeer-gateway is not on PyPI yet; pull it from the branch.

0 commit comments

Comments
 (0)