Skip to content

Commit f215631

Browse files
rickstaaclaude
andcommitted
feat(echo): add a robot mode that transforms the audio (#58)
echo dropped audio in three places, so trickle looked video-only: the client decoded video=0, the runner returned None for non-video frames, and only a video track was published. A stream with sound came back silent with no explanation. robot ring-modulates the audio and leaves the video alone, which makes the round trip audible. Ring modulation rather than a pitch shift because it preserves the sample count, so audio stays in sync without resampling. The output track is pinned to 48 kHz since opus rejects 44.1, which every consumer source produces. Only robot publishes an audio track: the container opens once every declared track has a first frame, so declaring audio for a silent input would stall the video. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent a22114d commit f215631

6 files changed

Lines changed: 216 additions & 16 deletions

File tree

.pre-commit-config.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,15 @@ repos:
2424
- id: end-of-file-fixer
2525
- id: check-yaml
2626
- id: check-merge-conflict
27+
28+
- repo: local
29+
hooks:
30+
- id: orchestrator-drift
31+
# api-proxy and vllm restate the shared orchestrator command because they
32+
# add -liveRunnerConfig and `extends` replaces a list. Keep the copies honest.
33+
name: orchestrator command drift
34+
entry: python3 scripts/check_orchestrator_drift.py
35+
language: python
36+
additional_dependencies: [pyyaml]
37+
pass_filenames: false
38+
files: ^(compose\.orchestrator\.yml|compose\.onchain\.yml|[^/]+/compose(\.onchain)?\.yml)$

echo/README.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Echo app (trickle realtime video)
22

3-
A realtime video app on the Livepeer network: it receives a live video stream over **trickle** channels, optionally transforms each frame (gray / invert / blur), and echoes it back. This is the **live/stateful** path — continuous media over trickle, not request/response — so the app embeds the SDK and self-registers (dynamic).
3+
A realtime video app on the Livepeer network: it receives a live video stream over **trickle** channels, optionally transforms each frame (gray / invert / blur) or the audio (robot), and echoes it back. This is the **live/stateful** path — continuous media over trickle, not request/response — so the app embeds the SDK and self-registers (dynamic).
44

55
| | |
66
| ------------ | ------------------------------------ |
@@ -73,10 +73,27 @@ Swap `/dev/video0` for your node. If that size/format isn't supported, list the
7373

7474
The `ffplay` low-delay flags (`-fflags nobuffer -flags low_delay -framedrop`) keep the preview close to realtime; drop them and it buffers.
7575

76-
- `--mode` picks the transform: `echo` (passthrough, the default), `gray`, `invert`, or `blur`. Use `--mode blur` on any command above to see the echo visibly transform the stream.
76+
- `--mode` picks the transform: `echo` (passthrough, the default), `gray`, `invert`, `blur`, or `robot`. Use `--mode blur` on any command above to see the echo visibly transform the stream.
77+
- `robot` ring-modulates the audio and leaves the video alone. It is the only mode that publishes an audio track, since a declared track that never gets a frame stalls the stream.
7778
- `blur` sweeps the radius `0 -> max -> 0` live (driving `/update`); `--blur-period N` sets the seconds per sweep cycle (default 2; larger is slower). `gray`/`invert` are static.
7879
- `--radius N` sets the initial blur strength, `--max-frames N` stops early.
7980

81+
**Hearing `robot`** — every command above is video-only, so `robot` would refuse them. Record yourself with a microphone (`arecord -l` lists capture devices), then play both files:
82+
83+
```sh
84+
ffmpeg -f v4l2 -input_format mjpeg -video_size 1280x720 -framerate 30 -i /dev/video0 \
85+
-f alsa -i plughw:1,0 -filter_complex "[1:a]aresample=async=1:first_pts=0[a]" \
86+
-map 0:v -map "[a]" -fps_mode cfr -t 10 \
87+
-c:v libx264 -preset ultrafast -pix_fmt yuv420p -g 30 -c:a aac -ar 48000 -f mpegts -y me.ts
88+
89+
uv run client.py --mode robot --output me-robot.ts me.ts
90+
ffplay -autoexit me.ts && ffplay -autoexit me-robot.ts # you, then you ring-modulated
91+
```
92+
93+
To hear it live instead, keep the same capture and swap the tail for `--output - -` piped into `ffplay -fflags nobuffer -i -`. Expect 2 to 4 seconds of lag, since trickle publishes in 2s segments, and wear headphones or the mic re-records the playback.
94+
95+
The camera and the audio device are separate clocks, so `aresample` and `-fps_mode cfr` align them; without both the publisher fails at the first segment boundary. On a multi-input interface add `-channels 6` and pick one input with `pan=mono|c0=c0`.
96+
8097
Stop the stack with `docker compose down`.
8198

8299
## Run on-chain (paid)

echo/client.py

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,20 @@
3232
from livepeer_gateway.errors import LivepeerGatewayError
3333
from livepeer_gateway.live_runner import stop_runner_session
3434
from livepeer_gateway.media_output import MediaOutput
35-
from livepeer_gateway.media_publish import MediaPublish
35+
from livepeer_gateway.media_publish import (
36+
AudioOutputConfig,
37+
MediaPublish,
38+
MediaPublishConfig,
39+
VideoOutputConfig,
40+
)
3641
from livepeer_gateway.http import post_json
3742
from livepeer_gateway.selection import reserve_session
3843

3944
DEFAULT_DISCOVERY = "https://localhost:8935/discovery"
4045
APP_ID = "livepeer-example/echo"
4146
DEFAULT_OUTPUT = "echo-out.ts"
4247
MAX_BLUR_RADIUS = 100
43-
MODES = ("echo", "gray", "invert", "blur")
48+
MODES = ("echo", "gray", "invert", "blur", "robot")
4449

4550
log = logging.getLogger("echo-client")
4651

@@ -79,7 +84,8 @@ def _parse_args() -> argparse.Namespace:
7984
choices=MODES,
8085
default="echo",
8186
help=(
82-
"Transform the runner applies: echo (passthrough), gray, invert, or blur. "
87+
"Transform the runner applies: echo (passthrough), gray, invert, blur, "
88+
"or robot (ring-modulates the audio). "
8389
"blur sweeps the radius; the rest are static."
8490
),
8591
)
@@ -117,7 +123,20 @@ async def _publish_video(
117123
raise LivepeerGatewayError(
118124
f"No video stream found in input: {input_source}"
119125
)
120-
publisher = MediaPublish(publish_url) # Livepeer: 2 (publish frames)
126+
# Only robot touches audio, so only robot publishes an audio track: the
127+
# container waits for a first frame on every track it declares.
128+
send_audio = mode == "robot"
129+
if send_audio and not input_.streams.audio:
130+
raise LivepeerGatewayError(
131+
f"robot needs audio, but the input has none: {input_source}"
132+
)
133+
tracks: list[VideoOutputConfig | AudioOutputConfig] = [VideoOutputConfig()]
134+
if send_audio:
135+
# Pinned: opus rejects 44.1 kHz, so let MediaPublish resample to 48.
136+
tracks.append(AudioOutputConfig(sample_rate=48000))
137+
publisher = MediaPublish( # Livepeer: 2 (publish frames)
138+
publish_url, config=MediaPublishConfig(tracks=tracks)
139+
)
121140
prev_pts_time: float | None = None
122141
prev_wall: float | None = None
123142
next_update_pts_time: float | None = None
@@ -126,9 +145,18 @@ async def _publish_video(
126145
# blur sweeps 0->max->0 (2*MAX steps); spread one full cycle over blur_period.
127146
update_interval = blur_period / (2 * MAX_BLUR_RADIUS)
128147

148+
video_index = 0
129149
try:
130-
for index, frame in enumerate(input_.decode(video=0), start=1):
131-
if max_frames > 0 and index > max_frames:
150+
# decode() yields both streams interleaved; without audio, stay on the
151+
# video stream alone. Pacing and the blur sweep run off video frames only.
152+
frames = input_.decode() if send_audio else input_.decode(video=0)
153+
for frame in frames:
154+
if not isinstance(frame, av.VideoFrame):
155+
await publisher.write_frame(frame)
156+
continue
157+
158+
video_index += 1
159+
if max_frames > 0 and video_index > max_frames:
132160
break
133161
current_pts_time = None
134162
if frame.pts is not None and frame.time_base is not None:
@@ -209,7 +237,13 @@ async def main() -> None:
209237
async with session:
210238
echo = await post_json(
211239
f"{session.app_url.rstrip('/')}/echo",
212-
{"radius": args.radius, "mode": args.mode},
240+
# robot is the mode that transforms audio, so it is also the one
241+
# that asks the runner for an audio track.
242+
{
243+
"radius": args.radius,
244+
"mode": args.mode,
245+
"audio": args.mode == "robot",
246+
},
213247
)
214248
in_url = _channel_url(echo, "in")
215249
out_url = _channel_url(echo, "out")

echo/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ requires-python = ">=3.12"
66
dependencies = [
77
"av", # client + runner: decode/encode video frames
88
"opencv-python-headless", # runner: gray/invert/blur transforms
9+
"numpy", # runner: robot ring modulation
910
"aiohttp",
1011
"livepeer-gateway",
1112
]

echo/runner.py

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,18 +25,27 @@
2525
from typing import Any
2626

2727
import av
28+
import numpy as np
2829
from aiohttp import web
2930

3031
from livepeer_gateway.live_runner import register_runner
3132
from livepeer_gateway.media_decode import AudioDecodedMediaFrame, VideoDecodedMediaFrame
3233
from livepeer_gateway.media_output import MediaOutput
33-
from livepeer_gateway.media_publish import MediaPublish
34+
from livepeer_gateway.media_publish import (
35+
AudioOutputConfig,
36+
MediaPublish,
37+
MediaPublishConfig,
38+
VideoOutputConfig,
39+
)
3440

3541
log = logging.getLogger("echo")
3642

3743
DEFAULT_HOST = "127.0.0.1"
3844
DEFAULT_PORT = 8989
39-
MODES = frozenset({"echo", "gray", "invert", "blur"})
45+
MODES = frozenset({"echo", "gray", "invert", "blur", "robot"})
46+
# "robot" multiplies each sample by a sine at this frequency; the sample count is
47+
# unchanged, so audio stays in sync with video.
48+
ROBOT_HZ = 220.0
4049

4150
state: EchoSession | None = None
4251

@@ -121,15 +130,37 @@ def _odd_kernel(radius: int) -> int:
121130
return min(kernel, 99)
122131

123132

133+
def _robot_audio(frame: av.AudioFrame) -> av.AudioFrame:
134+
# sample[i] *= sin(2*pi*ROBOT_HZ*t[i]). The carrier phase comes from the frame's
135+
# own timestamp, so it stays continuous across frames (no clicks) without keeping
136+
# state, and |carrier| <= 1 means it cannot clip.
137+
samples = frame.to_ndarray()
138+
t0 = float(frame.pts * frame.time_base) if frame.pts is not None else 0.0
139+
t = t0 + np.arange(samples.shape[-1], dtype=np.float32) / frame.sample_rate
140+
carrier = np.sin(2.0 * np.pi * ROBOT_HZ * t).astype(np.float32)
141+
out = av.AudioFrame.from_ndarray(
142+
(samples.astype(np.float32) * carrier).astype(samples.dtype),
143+
format=frame.format.name,
144+
layout=frame.layout.name,
145+
)
146+
out.sample_rate = frame.sample_rate
147+
out.pts = frame.pts
148+
out.time_base = frame.time_base
149+
return out
150+
151+
124152
def _transform_frame(
125153
decoded: AudioDecodedMediaFrame | VideoDecodedMediaFrame,
126154
mode: ModeState,
127-
) -> av.VideoFrame | None:
155+
) -> av.VideoFrame | av.AudioFrame | None:
156+
frame = decoded.frame
157+
if decoded.kind == "audio":
158+
# Audio rides along untouched; only "robot" transforms it.
159+
return _robot_audio(frame) if mode.mode == "robot" else frame
128160
if decoded.kind != "video":
129161
return None
130162

131-
frame = decoded.frame
132-
if mode.mode == "echo":
163+
if mode.mode in ("echo", "robot"): # robot changes audio only
133164
return frame
134165

135166
import cv2
@@ -175,9 +206,21 @@ async def _handle_echo(request: web.Request) -> web.Response:
175206
)
176207

177208
# for production apps, handle errors
178-
mode = _parse_mode(json.loads(await request.read()))
209+
payload = json.loads(await request.read())
210+
mode = _parse_mode(payload)
211+
# Tracks are declared upfront and the container waits for a first frame on each,
212+
# so only declare audio when the client says it is sending some.
213+
tracks: list[VideoOutputConfig | AudioOutputConfig] = [VideoOutputConfig()]
214+
send_audio = payload.get("audio", False)
215+
if not isinstance(send_audio, bool):
216+
raise web.HTTPBadRequest(text="audio must be a boolean")
217+
if send_audio:
218+
tracks.append(AudioOutputConfig())
179219
# internal_url: runner-reachable address (same as the public url on a shared net).
180-
publisher = MediaPublish(by_name["out"].get("internal_url", by_name["out"]["url"]))
220+
publisher = MediaPublish(
221+
by_name["out"].get("internal_url", by_name["out"]["url"]),
222+
config=MediaPublishConfig(tracks=tracks),
223+
)
181224

182225
async def _on_frame(decoded) -> None:
183226
frame = _transform_frame(decoded, mode)
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
#!/usr/bin/env python3
2+
"""Fail if an example's restated orchestrator command has drifted from the shared one.
3+
4+
`compose.orchestrator.yml` and `compose.onchain.yml` define the orchestrator once and
5+
examples pull it in with `extends`. Static runners cannot: they need an extra
6+
`-liveRunnerConfig` flag, and `extends` replaces a command list rather than appending
7+
to it, so they restate the whole thing. This checks those copies still match.
8+
9+
Run directly, or via pre-commit. Exits non-zero and prints what differs.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import subprocess
15+
import sys
16+
from pathlib import Path
17+
18+
import yaml
19+
20+
# Flags a copy may add to the shared command. Anything else is drift.
21+
ALLOWED_EXTRA = {"-liveRunnerConfig"}
22+
23+
SHARED = {
24+
"compose.yml": Path("compose.orchestrator.yml"),
25+
"compose.onchain.yml": Path("compose.onchain.yml"),
26+
}
27+
28+
29+
def _command(path: Path) -> list[str] | None:
30+
doc = yaml.safe_load(path.read_text()) or {}
31+
service = (doc.get("services") or {}).get("orchestrator") or {}
32+
command = service.get("command")
33+
return command if isinstance(command, list) else None
34+
35+
36+
def _flag(item: str) -> str:
37+
return str(item).split("=", 1)[0]
38+
39+
40+
def main() -> int:
41+
root = Path(__file__).resolve().parent.parent
42+
tracked = subprocess.run(
43+
["git", "ls-files", "*/compose.yml", "*/compose.onchain.yml"],
44+
cwd=root,
45+
capture_output=True,
46+
text=True,
47+
check=True,
48+
).stdout.split()
49+
50+
problems: list[str] = []
51+
checked = 0
52+
for rel in sorted(tracked):
53+
path = root / rel
54+
copy = _command(path)
55+
if copy is None: # uses `extends` alone, nothing to drift
56+
continue
57+
shared_path = root / SHARED[Path(rel).name]
58+
shared = _command(shared_path) or []
59+
checked += 1
60+
61+
shared_by_flag = {_flag(i): i for i in shared}
62+
copy_by_flag = {_flag(i): i for i in copy}
63+
64+
for flag, item in shared_by_flag.items():
65+
if flag not in copy_by_flag:
66+
problems.append(f"{rel}: missing {item!r} (in {shared_path.name})")
67+
elif copy_by_flag[flag] != item:
68+
problems.append(
69+
f"{rel}: {flag} is {copy_by_flag[flag]!r}, "
70+
f"{shared_path.name} has {item!r}"
71+
)
72+
for flag, item in copy_by_flag.items():
73+
if flag not in shared_by_flag and flag not in ALLOWED_EXTRA:
74+
problems.append(f"{rel}: unexpected {item!r} not in {shared_path.name}")
75+
76+
if problems:
77+
print("orchestrator command drift:\n")
78+
for p in problems:
79+
print(f" {p}")
80+
print(
81+
"\nThese examples restate the shared command because they add "
82+
f"{sorted(ALLOWED_EXTRA)} and `extends` cannot append to a list. "
83+
"Re-copy the shared command, or widen ALLOWED_EXTRA if the new flag "
84+
"is deliberate."
85+
)
86+
return 1
87+
88+
print(f"orchestrator command: {checked} restated copies match the shared files")
89+
return 0
90+
91+
92+
if __name__ == "__main__":
93+
sys.exit(main())

0 commit comments

Comments
 (0)