Skip to content

Commit 45329c6

Browse files
committed
feat(source): add source@v1 capture-and-stream client
Add a 'sendspin source' command that runs a source client: capture audio from a local input (line-in/microphone) or a sine test tone, encode it (PCM, FLAC, or Opus via PyAV), and stream it to a server, which mixes and distributes it to players. The server controls when the source streams. - source_utils: PyAV SourceEncoder (exposes raw codec header) + RMS level - source_stream: SourceStreamer capture loop (sounddevice / sine), server start/stop handling, optional line-sense signal reporting - cli: 'source' subcommand and 'audio-devices inputs' listing - audio_devices: input-device enumeration - settings: SourceSettings persistence (settings-source.json) - README: source mode documentation
1 parent 22b9ff3 commit 45329c6

8 files changed

Lines changed: 1010 additions & 1 deletion

File tree

README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,46 @@ Use `--manufacturer` and `--product-name` to override the device identity report
296296
sendspin daemon --name "Living Room" --manufacturer "Acme" --product-name "Living Room Speaker"
297297
```
298298

299+
### Source Mode
300+
301+
Run as a **source** client to capture audio from a local input (line-in, turntable,
302+
Bluetooth receiver, or microphone) and stream it *into* Sendspin. The server mixes
303+
and distributes it to players like any other audio, so an analog input on one machine
304+
can play back synchronized across the whole group.
305+
306+
```bash
307+
# Capture the default input device and stream it to a discovered server
308+
sendspin source
309+
310+
# Pick a specific server, input device, and codec
311+
sendspin source --url ws://192.168.1.50:8927/sendspin --device 2 --codec flac
312+
313+
# Stream a 440 Hz sine test tone (no capture hardware needed)
314+
sendspin source --input sine
315+
```
316+
317+
List available capture devices:
318+
319+
```bash
320+
sendspin audio-devices inputs
321+
```
322+
323+
Key options:
324+
325+
- `--input {linein,sine}` — capture from a real input device, or generate a sine test tone. Defaults to `linein`; passing `--device` implies `linein`.
326+
- `--device` — input device index or name (see `audio-devices inputs`).
327+
- `--codec {pcm,opus,flac}` — codec used to encode captured audio before sending (default `pcm`). Capture is 16-bit.
328+
- `--sample-rate` / `--channels` — capture format (default 48000 Hz, 2 channels).
329+
- `--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.
330+
331+
The **server** decides when a source streams: a source stays idle until the server
332+
sends a `start` command, and stops on `stop` or disconnect. A device may run both the
333+
`source` and `player` roles; when it does, it never plays its captured input locally —
334+
it only plays back what the server distributes, staying in sync with the group.
335+
336+
Source-mode preferences (client id, last server, input/codec defaults) are persisted
337+
to `~/.config/sendspin/settings-source.json`.
338+
299339
### Hooks
300340

301341
You can run external commands when audio streams start or stop. This is useful for controlling amplifiers, lighting, or other home automation:

sendspin/audio_devices.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,38 @@ def query_devices() -> list[AudioDevice]:
8080
return result
8181

8282

83+
@dataclass(slots=True)
84+
class InputDevice:
85+
"""Represents an audio input (capture) device."""
86+
87+
index: int
88+
name: str
89+
input_channels: int
90+
sample_rate: float
91+
is_default: bool
92+
93+
94+
def query_input_devices() -> list[InputDevice]:
95+
"""Query all available audio input (capture) devices."""
96+
devices = sounddevice.query_devices()
97+
default_input = int(sounddevice.default.device[0])
98+
99+
result: list[InputDevice] = []
100+
for i in range(len(devices)):
101+
dev = devices[i]
102+
if dev["max_input_channels"] > 0:
103+
result.append(
104+
InputDevice(
105+
index=i,
106+
name=str(dev["name"]),
107+
input_channels=int(dev["max_input_channels"]),
108+
sample_rate=float(dev["default_samplerate"]),
109+
is_default=(i == default_input),
110+
)
111+
)
112+
return result
113+
114+
83115
def _check_format(device: AudioDevice, rate: int, channels: int, dtype: str) -> bool:
84116
"""Check if a specific audio format is supported by the device."""
85117
try:

0 commit comments

Comments
 (0)