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
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,52 @@ Use `--manufacturer` and `--product-name` to override the device identity report
sendspin daemon --name "Living Room" --manufacturer "Acme" --product-name "Living Room Speaker"
```

### Source Mode

Run as a **source** client to capture audio from a local input (line-in, turntable,
Bluetooth receiver, or microphone) and stream it *into* Sendspin. The server mixes
and distributes it to players like any other audio, so an analog input on one machine
can play back synchronized across the whole group.

```bash
# Capture the default input device and stream it to a discovered server
sendspin source

# Pick a specific server, input device, and codec
sendspin source --url ws://192.168.1.50:8927/sendspin --device 2 --codec flac

# Capture directly from an ALSA PCM device
sendspin source --device hw:CARD=USB,DEV=0

# Stream a 440 Hz sine test tone (no capture hardware needed)
sendspin source --input sine
```

List available capture devices:

```bash
sendspin audio-devices inputs
```

Key options:

- `--input {linein,sine}` — capture from a real input device, or generate a sine test tone. Defaults to `linein`; passing `--device` implies `linein`.
- `--device` — input device index, name, or raw ALSA PCM name (see `audio-devices inputs`; raw ALSA capture requires `arecord`).
- `--codec {pcm,opus,flac}` — codec used to encode captured audio before sending (default `pcm`). Capture is 16-bit.
- `--sample-rate` / `--channels` — capture format (default 48000 Hz, 2 channels).
- `--line-sense` — report input signal presence to the server via `client/state`; the server may use it to decide when to start/stop the source.

The **server** decides when a source streams: a source stays idle until the server
sends a `start` command, and stops on `stop` or disconnect. This is a policy the
server application makes for itself — the `aiosendspin` library never starts a
source on its own — so a source connected to a server that does not ask for audio
stays idle by design. A device may run both the `source` and `player` roles; when
it does, it never plays its captured input locally — it only plays back what the
server distributes, staying in sync with the group.

Source-mode preferences (client id, last server, input/codec defaults) are persisted
to `~/.config/sendspin/settings-source.json`.

### Hooks

You can run external commands when audio streams start or stop. This is useful for controlling amplifiers, lighting, or other home automation:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ classifiers = [
"Programming Language :: Python :: 3.13",
]
dependencies = [
"aiosendspin[server]~=6.0.1",
"aiosendspin[server,source]~=9.1.0",
"aiosendspin-mpris~=2.1.1",
"av>=15.0.0",
"numpy>=1.26.0",
Expand Down
4 changes: 2 additions & 2 deletions sendspin/audio_connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from typing import TYPE_CHECKING, cast

from aiosendspin.models.core import StreamStartMessage
from aiosendspin.models.types import AudioCodec, ClientStateType
from aiosendspin.models.types import AudioCodec

from sendspin.audio import AudioPlayer
from sendspin.audio_devices import AudioDevice
Expand Down Expand Up @@ -454,7 +454,7 @@ def send_player_volume(self) -> None:
if self._client is not None and self._client.connected:
create_task(
self._client.send_player_state(
state=ClientStateType.SYNCHRONIZED,
available=True,
volume=self._volume,
muted=self._muted,
)
Expand Down
71 changes: 70 additions & 1 deletion sendspin/audio_devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,47 @@ def query_devices() -> list[AudioDevice]:
return result


@dataclass(slots=True)
class InputDevice:
"""Represents an audio input (capture) device."""

index: int | None
name: str
input_channels: int
sample_rate: float
is_default: bool
alsa_device_name: str | None = None

@property
def device_id(self) -> int | str:
"""Return the identifier to pass to sounddevice APIs."""
if self.alsa_device_name is not None:
return self.alsa_device_name
assert self.index is not None
return self.index


def query_input_devices() -> list[InputDevice]:
"""Query all available audio input (capture) devices."""
devices = sounddevice.query_devices()
default_input = int(sounddevice.default.device[0])

result: list[InputDevice] = []
for i in range(len(devices)):
dev = devices[i]
if dev["max_input_channels"] > 0:
result.append(
InputDevice(
index=i,
name=str(dev["name"]),
input_channels=int(dev["max_input_channels"]),
sample_rate=float(dev["default_samplerate"]),
is_default=(i == default_input),
)
)
return result


def _check_format(device: AudioDevice, rate: int, channels: int, dtype: str) -> bool:
"""Check if a specific audio format is supported by the device."""
try:
Expand Down Expand Up @@ -238,9 +279,18 @@ def list_alsa_devices() -> list[tuple[str, str]]:
Returns a list of (device_name, description) tuples for output devices.
Returns an empty list if aplay is not available.
"""
return _list_alsa_devices("aplay")


def list_alsa_input_devices() -> list[tuple[str, str]]:
"""List ALSA capture PCM devices from ``arecord -L``."""
return _list_alsa_devices("arecord")


def _list_alsa_devices(command: str) -> list[tuple[str, str]]:
try:
result = subprocess.run(
["aplay", "-L"], # noqa: S607
[command, "-L"], # noqa: S607
capture_output=True,
text=True,
timeout=5,
Expand Down Expand Up @@ -268,6 +318,25 @@ def list_alsa_devices() -> list[tuple[str, str]]:
return devices


def resolve_input_device(device_arg: str | None) -> InputDevice:
"""Resolve an input device by index, name prefix, or raw ALSA name."""
devices = query_input_devices()
if device_arg is None:
device = next((device for device in devices if device.is_default), None)
elif device_arg.isnumeric():
device = next((device for device in devices if device.index == int(device_arg)), None)
else:
device = next((device for device in devices if device.name.startswith(device_arg)), None)

if device is None and device_arg is not None and sys.platform.startswith("linux"):
if device_arg in {name for name, _ in list_alsa_input_devices()}:
return InputDevice(None, device_arg, 2, 48000.0, False, device_arg)

if device is None:
raise ValueError(f"Audio input device '{device_arg or 'default'}' not found.")
return device


def resolve_audio_device(device_arg: str | None) -> AudioDevice:
"""Resolve audio device from a CLI argument.

Expand Down
Loading