Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ cusp stream -d "USB Audio" -t "Living Room"
# From the system audio output on Linux (see "System audio" below)
cusp stream -d system -t "Living Room"

# Stream to an AirPlay 2 group (see "AirPlay 2 groups" below)
cusp stream -d "USB Audio" -t "Living Room,Kitchen,Office"

# Or use a config file
cusp stream --config cusp.toml
```
Expand All @@ -66,7 +69,7 @@ The `-d` flag accepts:
- a device name (substring match) or index number from `cusp devices`
- the literal `system` to capture system audio output (Linux only)

The `-t` flag accepts an AirPlay receiver name.
The `-t` flag accepts an AirPlay receiver name, or a comma-separated list for a group.

### Pair with a device

Expand Down Expand Up @@ -144,6 +147,27 @@ log_level = "INFO"

Config file search order: `--config` flag, then `./cusp.toml`, then `~/.config/cusp/cusp.toml`. Command line arguments override config file values.

## AirPlay 2 groups

cusp can fan a single capture out to multiple AirPlay receivers at once. Pass a comma-separated list on the CLI, or a TOML array in the config:

```bash
cusp stream -d "USB Audio" -t "Living Room,Kitchen,Office"
```

```toml
[airplay]
target = ["Living Room", "Kitchen", "Office"]
```

Single-target usage is unchanged — `-t "Living Room"` or `target = "Living Room"` still works and connects to one device.

**Leader semantics.** The first name in the list is the group leader; the rest are followers. The leader is what cusp retries against if no device can be reached at startup, and its name is used for logging and for looking up stored pairing credentials.

**Graceful degradation.** If a follower can't be resolved on the network or its initial connection fails, cusp logs a warning and keeps streaming to whichever devices did connect. If a follower drops mid-stream, the same applies — the rest of the group keeps playing. The session only fails (and triggers `auto_reconnect`) when every device has dropped. You can leave an occasionally-offline speaker in the list without breaking playback for the rest of the house.

**Sync drift.** Each receiver gets its own RAOP session — there is no shared timing anchor between them. Receivers in the same room will drift enough to be audible over time (on the order of tens of milliseconds within a few minutes). This is a known tradeoff of running an application-level fan-out instead of a true Apple-managed AirPlay 2 group. If you need tightly synced playback in a single room, group the speakers in the Home app and target the group leader's name from cusp instead.

## Running on a Raspberry Pi

### Systemd service
Expand Down
12 changes: 10 additions & 2 deletions cusp.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,17 @@ device = "USB Audio"
blocksize = 1024

[airplay]
# AirPlay receiver name (substring match)
# Run `cusp devices` to see available receivers
# AirPlay receiver name (substring match).
# Run `cusp devices` to see available receivers.
target = "Living Room"
# To stream to a group, pass a list of names; the first is the leader
# and the rest are followers. If a follower can't be reached, it is
# skipped with a warning and the stream continues to the survivors.
# Note: each receiver gets its own RAOP session, so playback will drift
# between them over time (audible within a few minutes in the same room).
# For tightly synced playback, group the speakers in the Home app and
# target the group leader's name here.
# target = ["Living Room", "Kitchen", "Office"]
# password = "secret"

[behavior]
Expand Down
76 changes: 54 additions & 22 deletions src/cusp/airplay.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,36 +82,68 @@ async def pair_device(name: str) -> None:
raise RuntimeError(f"No RAOP service found on {target.name}")


async def resolve_target(config: CuspConfig) -> BaseConfig:
"""Scan for the configured AirPlay target and apply credentials/password.
def _apply_auth(target: BaseConfig, config: CuspConfig) -> None:
"""Attach stored RAOP credentials and the configured password to a target."""
creds = _load_credentials()
stored = creds.get(str(target.identifier))
for service in target.services:
if service.protocol != Protocol.RAOP:
continue
if stored and "raop" in stored:
service.credentials = stored["raop"]
if config.airplay_password:
service.password = config.airplay_password


Returns a pyatv device config ready to be passed to `connect_target`.
Raises ConnectionError if the device is not found on the network.
async def resolve_targets(config: CuspConfig) -> list[BaseConfig]:
"""Resolve all configured AirPlay targets in a single network scan.

Returns the surviving devices in configured order — the first entry is the
group leader. Followers that are missing or ambiguous are logged and
skipped so one offline speaker cannot kill the session. An ambiguous
*leader* still fails hard (surfaces as ValueError from `_find_device`).

Raises ConnectionError with the list of missed names if zero devices
resolve.
"""
if not config.airplay_target:
raise ConnectionError("No AirPlay target configured")

loop = asyncio.get_event_loop()
devices = await pyatv.scan(loop, timeout=5)
target = _find_device(devices, config.airplay_target)
if target is None:

resolved: list[BaseConfig] = []
missed: list[str] = []
for index, name in enumerate(config.airplay_target):
is_leader = index == 0
try:
target = _find_device(devices, name)
except ValueError:
if is_leader:
raise
logger.warning("AirPlay follower '%s' ambiguous, skipping", name)
missed.append(name)
continue
if target is None:
role = "leader" if is_leader else "follower"
logger.warning("AirPlay %s '%s' not found, skipping", role, name)
missed.append(name)
continue
_apply_auth(target, config)
resolved.append(target)

if not resolved:
raise ConnectionError(
f"AirPlay device '{config.airplay_target}' not found on network. "
f"No AirPlay devices found on network (tried: {', '.join(missed)}). "
"Run `cusp devices` to see available receivers."
)

# Apply stored credentials
creds = _load_credentials()
if str(target.identifier) in creds:
stored = creds[str(target.identifier)]
for service in target.services:
if service.protocol == Protocol.RAOP and "raop" in stored:
service.credentials = stored["raop"]

# Apply password if configured
if config.airplay_password:
for service in target.services:
if service.protocol == Protocol.RAOP:
service.password = config.airplay_password

return target
return resolved


async def resolve_target(config: CuspConfig) -> BaseConfig:
"""Resolve the configured leader. Thin wrapper over `resolve_targets`."""
return (await resolve_targets(config))[0]


async def connect_target(target: BaseConfig) -> AppleTV:
Expand Down
17 changes: 15 additions & 2 deletions src/cusp/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,15 @@ async def _pair_device(name: str) -> None:
default=None,
help='Audio input device name, index, or "system" to capture system audio.',
)
@click.option("-t", "--target", default=None, help="AirPlay receiver name.")
@click.option(
"-t",
"--target",
default=None,
help=(
"AirPlay receiver name. Pass a comma-separated list to stream to a "
"group (first name is the leader)."
),
)
@click.option(
"-c",
"--config",
Expand Down Expand Up @@ -116,10 +124,15 @@ def stream(
else:
audio_device = device

# Split -t on commas into a group list; first entry is the leader.
airplay_target: list[str] | None = None
if target is not None:
airplay_target = [t.strip() for t in target.split(",") if t.strip()]

config = load_config(
config_path=config_path,
audio_device=audio_device,
airplay_target=target,
airplay_target=airplay_target,
sample_rate=sample_rate,
channels=channels,
blocksize=blocksize,
Expand Down
24 changes: 21 additions & 3 deletions src/cusp/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ class CuspConfig:
channels: int | None = None
blocksize: int = 1024

# AirPlay target
airplay_target: str | None = None
# AirPlay target(s). First entry is the group leader; remaining are followers.
airplay_target: list[str] | None = None
airplay_password: str | None = None

# Behavior
Expand All @@ -32,6 +32,24 @@ class CuspConfig:
log_file: str | None = None


def _normalize_targets(value: object) -> list[str]:
"""Normalize a TOML `airplay.target` value to a list.

Accepts a TOML array (`["A", "B"]`) or a string; strings are split on
commas to match the CLI `-t` form, so `"A, B"` yields `["A", "B"]`.
"""
if isinstance(value, str):
items = value.split(",")
elif isinstance(value, list):
items = [str(v) for v in value]
else:
raise TypeError(
"airplay.target must be a string or array of strings, "
f"got {type(value).__name__}"
)
return [s.strip() for s in items if s.strip()]


def _find_config_file() -> Path | None:
candidates = [
Path("cusp.toml"),
Expand Down Expand Up @@ -68,7 +86,7 @@ def load_config(config_path: str | None = None, **cli_overrides: object) -> Cusp
if "blocksize" in audio:
data["blocksize"] = audio["blocksize"]
if "target" in airplay:
data["airplay_target"] = airplay["target"]
data["airplay_target"] = _normalize_targets(airplay["target"])
if "password" in airplay:
data["airplay_password"] = airplay["password"]
for key in (
Expand Down
Loading
Loading